번들 확장 README 제목이 manifest 확장명 그대로(「게시판」)라 그 문서만 연
사람이 그누보드7의 확장인지, 모듈인지 템플릿인지 알 수 없었다.
README · AGENTS.md · docs/README.md 진입 문서 60개의 제목을
「그누보드7 {확장명} {유형}」 으로 바꾸고, 조립 규칙을 ExtensionInventory::docTitle
한 곳에 두어 골격 생성기와 계약 테스트가 같은 헬퍼를 쓰게 했다.
확장명이 이미 유형으로 끝나면 겹쳐 붙이지 않는다.
공개 규정에는 번들 확장 전용 관례임을 주어로 명시하고 제3자 확장에는
요구하지 않는다는 문장을 두었다. 언어팩은 README 가 없어 대상 밖이다.
1434 lines
68 KiB
PHP
1434 lines
68 KiB
PHP
<?php
|
|
|
|
namespace Tests\Feature\Documentation;
|
|
|
|
use App\Support\ExtensionDoc\ExtensionDocContext;
|
|
use App\Support\ExtensionDoc\ExtensionDocScaffolder;
|
|
use App\Support\ExtensionDoc\ExtensionInventory;
|
|
use App\Support\ExtensionDoc\FrontendInventory;
|
|
use App\Support\ExtensionDoc\TestPathCollector;
|
|
use Tests\TestCase;
|
|
|
|
/**
|
|
* 확장 개발자 문서 계약 (#601)
|
|
*
|
|
* 세션 훅은 CI 에서 돌지 않는다. 문서 체계를 실제로 잠그는 것은 이 테스트다.
|
|
*
|
|
* 확장 목록을 **열거하지 않는다** — `_bundled` 를 스캔하므로 새 확장이 추가되면 자동으로
|
|
* 검사 대상에 편입된다. 그래서 21번째 확장이 문서 없이 태어나면 이 테스트가 먼저 붉어진다.
|
|
*
|
|
* 문서가 아직 없는 확장은 집필 백로그(`DOC_BACKLOG`)로 선언한다. 백로그는 **줄어들기만
|
|
* 해야 한다** — 목록에 없는 확장이 문서를 잃거나 새 확장이 문서 없이 들어오면 실패한다.
|
|
* 백로그가 비면 그 순간부터 전수 강제로 바뀌며, 별도 코드 변경이 필요 없다.
|
|
*/
|
|
class ExtensionDocContractTest extends TestCase
|
|
{
|
|
/**
|
|
* 개발자 문서 집필 대기 중인 확장 (`{type}/{id}`).
|
|
*
|
|
* S1 은 인프라만 구축했고 집필은 S2(파일럿 3 + 동형군 6)·S3(잔여 11)에서 수행한다.
|
|
* 확장 하나를 집필할 때마다 이 목록에서 그 줄을 지운다. 목록에 항목을 **추가하는 것은
|
|
* 금지**다 — 추가는 곧 문서 없는 확장을 새로 들이는 것이고, 그러면 이 계약이 무의미해진다.
|
|
*
|
|
* @var array<int, string>
|
|
*/
|
|
private const DOC_BACKLOG = [
|
|
];
|
|
|
|
/**
|
|
* 집필 백로그의 상한 (S1 종료 시점의 번들 확장 수).
|
|
*
|
|
* "추가 금지" 를 주석으로만 적으면 강제되지 않습니다 — 줄 하나를 보태면 문서 없는 확장이
|
|
* 조용히 통과하고 아무 테스트도 red 가 되지 않습니다. 상한을 상수로 고정해, 백로그를
|
|
* 늘리려면 이 숫자를 올리는 **눈에 보이는 행위**를 거치게 합니다. 집필이 진행되면 이
|
|
* 숫자도 함께 내려갑니다 (백로그는 줄어들기만 하므로 상한도 단조 감소한다).
|
|
*/
|
|
private const DOC_BACKLOG_CEILING = 0;
|
|
|
|
/**
|
|
* `_bundled` 스캔이 번들 확장 전수를 발견하는지 확인합니다.
|
|
*/
|
|
public function test_inventory_discovers_every_bundled_extension(): void
|
|
{
|
|
$records = (new ExtensionInventory)->collect('all');
|
|
|
|
$this->assertNotEmpty($records, '번들 확장을 하나도 발견하지 못했습니다.');
|
|
|
|
foreach ($records as $record) {
|
|
$this->assertDirectoryExists($record['path']);
|
|
$this->assertFileExists($record['manifestPath']);
|
|
$this->assertNotSame('', $record['id']);
|
|
$this->assertContains($record['type'], ExtensionInventory::types());
|
|
}
|
|
|
|
// 디스크의 manifest 개수와 스캔 결과가 일치해야 한다 (조용한 누락 금지).
|
|
$onDisk = 0;
|
|
foreach ([['modules', 'module.json'], ['plugins', 'plugin.json'], ['templates', 'template.json']] as [$dir, $manifest]) {
|
|
$root = base_path($dir.'/_bundled');
|
|
if (! is_dir($root)) {
|
|
continue;
|
|
}
|
|
$onDisk += count(glob($root.'/*/'.$manifest) ?: []);
|
|
}
|
|
|
|
$this->assertSame($onDisk, count($records), '스캔 결과가 디스크의 manifest 수와 다릅니다.');
|
|
}
|
|
|
|
/**
|
|
* 백로그에 없는 확장은 필수 문서를 갖추고 있어야 합니다.
|
|
*
|
|
* 백로그 자신도 검사 대상입니다 — 실재하지 않는 확장이 남아 있으면 목록이 낡은 것이고,
|
|
* 문서를 갖춘 확장이 남아 있으면 지워야 할 줄을 지우지 않은 것입니다.
|
|
*/
|
|
public function test_documented_extensions_have_every_required_document(): void
|
|
{
|
|
$records = (new ExtensionInventory)->collect('all');
|
|
$backlog = array_flip(self::DOC_BACKLOG);
|
|
$ids = [];
|
|
$missing = [];
|
|
$staleBacklog = [];
|
|
|
|
foreach ($records as $record) {
|
|
$key = $record['type'].'/'.$record['id'];
|
|
$ids[$key] = true;
|
|
|
|
$present = [];
|
|
$absent = [];
|
|
|
|
foreach (ExtensionDocScaffolder::documentsForType($record['type']) as $doc) {
|
|
$abs = $record['path'].DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $doc);
|
|
if (is_file($abs)) {
|
|
$present[] = $doc;
|
|
} else {
|
|
$absent[] = $doc;
|
|
}
|
|
}
|
|
|
|
if (isset($backlog[$key])) {
|
|
if ($absent === []) {
|
|
$staleBacklog[] = $key.' — 문서가 완비되었으니 DOC_BACKLOG 에서 지우세요';
|
|
}
|
|
|
|
continue;
|
|
}
|
|
|
|
foreach ($absent as $doc) {
|
|
$missing[] = $key.' → '.$doc;
|
|
}
|
|
}
|
|
|
|
foreach (array_keys($backlog) as $key) {
|
|
if (! isset($ids[$key])) {
|
|
$staleBacklog[] = $key.' — 존재하지 않는 확장입니다 (DOC_BACKLOG 에서 지우세요)';
|
|
}
|
|
}
|
|
|
|
$this->assertSame([], $missing, "필수 문서 누락:\n".implode("\n", $missing));
|
|
$this->assertSame([], $staleBacklog, "DOC_BACKLOG 가 낡았습니다:\n".implode("\n", $staleBacklog));
|
|
|
|
// 백로그는 줄어들기만 한다 — 상한을 **정확히** 일치시켜 래칫으로 만든다.
|
|
//
|
|
// `<=` 로 두면 항목 하나를 집필해 지우는 순간(20 → 19) 상한 20 아래에 빈자리가
|
|
// 하나 영구히 열린다. 그 다음부터는 문서 없는 확장을 백로그에 얹어도 red 가 되지
|
|
// 않는다 — 막으려던 것이 첫 집필과 동시에 되살아난다. `===` 는 백로그에서 한 줄을
|
|
// 지울 때 상한도 함께 내리게 강제하고, 늘리려면 그 숫자를 올리는 행위가 diff 에 남는다.
|
|
$this->assertSame(
|
|
self::DOC_BACKLOG_CEILING,
|
|
count(self::DOC_BACKLOG),
|
|
'DOC_BACKLOG 와 DOC_BACKLOG_CEILING 이 어긋났습니다. 확장을 집필해 백로그에서 '
|
|
.'지웠다면 상한도 같은 수만큼 내리세요. 늘려야 할 근거가 정말 있다면 상한을 '
|
|
.'올리고 그 사유를 남기세요 — 그것이 "문서 없는 확장을 새로 들인다" 는 선언입니다.',
|
|
);
|
|
|
|
$this->assertSame(
|
|
array_values(array_unique(self::DOC_BACKLOG)),
|
|
array_values(self::DOC_BACKLOG),
|
|
'DOC_BACKLOG 에 중복 항목이 있습니다 (상한 판정이 왜곡됩니다).',
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 작성된 문서는 필수 섹션과 자동 생성 블록을 갖추고 있어야 합니다.
|
|
*
|
|
* 문서를 만들다 만 상태(헤딩만 있고 블록이 없거나 그 반대)를 잡습니다.
|
|
*
|
|
* 백로그 확장은 제외합니다 — 표준 골격 이전에 손으로 쓰인 README 5개가 그 상태이며,
|
|
* 재정비는 집필 단계의 작업입니다. 백로그에서 빠지는 순간 이 검사가 전면 적용됩니다.
|
|
*/
|
|
public function test_existing_documents_have_required_sections_and_blocks(): void
|
|
{
|
|
$records = (new ExtensionInventory)->collect('all');
|
|
$backlog = array_flip(self::DOC_BACKLOG);
|
|
$problems = [];
|
|
|
|
foreach ($records as $record) {
|
|
if (isset($backlog[$record['type'].'/'.$record['id']])) {
|
|
continue;
|
|
}
|
|
|
|
foreach (ExtensionDocScaffolder::documentsForType($record['type']) as $doc) {
|
|
$abs = $record['path'].DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $doc);
|
|
if (! is_file($abs)) {
|
|
continue;
|
|
}
|
|
|
|
$content = (string) file_get_contents($abs);
|
|
$meta = ExtensionDocScaffolder::DOCUMENTS[$doc];
|
|
$label = $record['type'].'/'.$record['id'].' → '.$doc;
|
|
|
|
foreach (ExtensionDocScaffolder::sectionsFor($doc, $record['type']) as $section) {
|
|
if (! ExtensionDocScaffolder::hasSection($content, $section)) {
|
|
$problems[] = $label.' : 필수 섹션 없음 — '.$section;
|
|
}
|
|
}
|
|
|
|
$present = ExtensionDocScaffolder::presentBlockKeys($content);
|
|
foreach (ExtensionDocScaffolder::blocksFor($doc) as $block) {
|
|
if (! in_array($block, $present, true)) {
|
|
$problems[] = $label.' : 자동 생성 블록 없음 — '.$block;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
$this->assertSame([], $problems, "문서 골격 위반:\n".implode("\n", $problems));
|
|
}
|
|
|
|
/**
|
|
* 자동 생성 블록의 훅 목록이 소스 실측과 일치해야 합니다 (문서 부패 검출).
|
|
*
|
|
* 훅은 다른 확장이 잡는 계약이므로, 문서와 코드가 어긋나면 그 확장이 잡을 수 없는
|
|
* 훅 이름을 문서가 광고하게 됩니다.
|
|
*/
|
|
public function test_generated_blocks_match_measured_source(): void
|
|
{
|
|
$inventory = new ExtensionInventory;
|
|
$scaffolder = new ExtensionDocScaffolder;
|
|
$drifted = [];
|
|
|
|
foreach ($inventory->collect('all') as $record) {
|
|
$documents = array_filter(
|
|
ExtensionDocScaffolder::documentsForType($record['type']),
|
|
fn (string $doc): bool => is_file($record['path'].DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $doc)),
|
|
);
|
|
|
|
if ($documents === []) {
|
|
continue;
|
|
}
|
|
|
|
$bodies = $scaffolder->renderBlocks($this->contextFor($record, $inventory));
|
|
|
|
foreach ($documents as $doc) {
|
|
$abs = $record['path'].DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $doc);
|
|
$content = (string) file_get_contents($abs);
|
|
|
|
$subject = [];
|
|
foreach (ExtensionDocScaffolder::blocksFor($doc) as $block) {
|
|
if (isset($bodies[$block])) {
|
|
$subject[$block] = $bodies[$block];
|
|
}
|
|
}
|
|
|
|
$merged = ExtensionDocScaffolder::replaceBlocks($content, $subject);
|
|
|
|
if (! $merged['unchanged']) {
|
|
$drifted[] = $record['type'].'/'.$record['id'].' → '.$doc;
|
|
}
|
|
}
|
|
}
|
|
|
|
$this->assertSame(
|
|
[],
|
|
$drifted,
|
|
"자동 생성 블록이 코드 실측과 어긋납니다 (`php artisan ext:docgen` 재실행 필요):\n".implode("\n", $drifted),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 드리프트 판정식 자체가 살아 있어야 합니다 (공허 통과 방지).
|
|
*
|
|
* 위 검사는 **문서를 가진 확장**만 대상으로 하는데, 표준 골격으로 전환된 확장이 아직
|
|
* 없어 실질 대상이 0건입니다. 그래서 판정식이 깨져도 계속 초록입니다 — "드리프트 없음"
|
|
* 과 "드리프트를 보지 않음" 이 구분되지 않습니다. 낡은 훅 표를 심은 합성 문서로 판정식이
|
|
* 실제로 드리프트를 잡는지 고정합니다.
|
|
*/
|
|
public function test_drift_detection_actually_detects_drift(): void
|
|
{
|
|
$stale = "# 확장점\n\n"
|
|
.ExtensionDocScaffolder::wrap('hooks-published', '| 훅 이름 | 유형 |\n|---|---|\n| `옛.훅.이름` | action |')
|
|
."\n";
|
|
|
|
$fresh = '| 훅 이름 | 유형 |'."\n".'|---|---|'."\n".'| `새.훅.이름` | action |';
|
|
|
|
$drifted = ExtensionDocScaffolder::replaceBlocks($stale, ['hooks-published' => $fresh]);
|
|
|
|
$this->assertFalse(
|
|
$drifted['unchanged'],
|
|
'블록 본문이 실측과 다른데 드리프트로 판정되지 않았습니다 — 판정식이 죽어 있습니다.',
|
|
);
|
|
$this->assertStringContainsString('새.훅.이름', $drifted['content']);
|
|
$this->assertStringNotContainsString('옛.훅.이름', $drifted['content']);
|
|
|
|
// 같은 본문이면 드리프트가 아니어야 한다 (거짓 양성 금지).
|
|
$same = ExtensionDocScaffolder::replaceBlocks($drifted['content'], ['hooks-published' => $fresh]);
|
|
$this->assertTrue($same['unchanged'], '동일 본문인데 드리프트로 판정했습니다.');
|
|
}
|
|
|
|
/**
|
|
* 훅 이름을 조립해 발행하는 확장도 발행 훅 표에 실려야 합니다.
|
|
*
|
|
* 리터럴 스캔만으로는 `self::PLUGIN_ID.'.consent.granted'` 형태를 읽지 못해, 훅을 12곳에서
|
|
* 발행하는 확장이 "훅을 발행하지 않습니다" 로 문서화됩니다. 그래서 확장이 `getHooks()` 로
|
|
* 선언한 목록이 1차 출처입니다 — 이 계약이 깨지면 그 확장의 확장점이 통째로 비공개가 되고,
|
|
* 문서는 사실이 아닌 문장을 광고합니다.
|
|
*/
|
|
public function test_declared_hooks_reach_the_published_table(): void
|
|
{
|
|
$inventory = new ExtensionInventory;
|
|
$scaffolder = new ExtensionDocScaffolder;
|
|
|
|
$records = array_values(array_filter(
|
|
$inventory->collect('plugin:sirsoft-gdpr'),
|
|
fn (array $r): bool => $r['id'] === 'sirsoft-gdpr',
|
|
));
|
|
|
|
$this->assertNotEmpty($records, 'sirsoft-gdpr 를 찾지 못했습니다.');
|
|
|
|
$ctx = $this->contextFor($records[0], $inventory);
|
|
|
|
$this->assertGreaterThan(
|
|
0,
|
|
$ctx['hooks']['publishedSites'],
|
|
'이 확장은 훅 발행 호출을 갖고 있어야 합니다 (전제 확인).',
|
|
);
|
|
$this->assertNotSame(
|
|
[],
|
|
$ctx['hooks']['published'],
|
|
'발행 호출이 있는데 발행 훅이 0종입니다 — 선언이 반영되지 않았습니다.',
|
|
);
|
|
|
|
$block = $scaffolder->renderBlocks($ctx)['hooks-published'];
|
|
|
|
$this->assertStringNotContainsString(
|
|
'훅을 발행하지 않습니다',
|
|
$block,
|
|
'훅을 발행하는 확장에 "발행하지 않습니다" 가 렌더되었습니다.',
|
|
);
|
|
|
|
foreach ($ctx['hooks']['published'] as $hook) {
|
|
$this->assertStringContainsString(
|
|
$hook['name'],
|
|
$block,
|
|
"선언된 훅 '{$hook['name']}' 이 발행 훅 표에 없습니다.",
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 자동 생성 블록 교체가 블록 밖 텍스트를 손상하지 않아야 합니다.
|
|
*
|
|
* 이 계약이 깨지면 사람이 쓴 서술이 재생성 때마다 소실됩니다 — `api:docgen` 이 과거
|
|
* 34,000줄을 날린 사고가 그 형태였고, 그래서 이 생성기에는 파괴적 재생성 플래그가 없습니다.
|
|
*/
|
|
public function test_block_replacement_preserves_human_text(): void
|
|
{
|
|
$human = [
|
|
'intro' => '사람이 쓴 서론 — 생성기가 건드리면 안 된다.',
|
|
'intent' => '사람이 쓴 의도 서술 — 블록 사이에 있어도 보존되어야 한다.',
|
|
'outro' => '사람이 쓴 마지막 문단.',
|
|
];
|
|
|
|
$document = "# 제목\n\n{$human['intro']}\n\n"
|
|
.ExtensionDocScaffolder::wrap('models', "| 옛 |\n|---|\n| 표 |')")
|
|
."\n\n<!-- @intent START -->\n{$human['intent']}\n<!-- @intent END -->\n\n"
|
|
.ExtensionDocScaffolder::wrap('tables', '옛 테이블 표')
|
|
."\n\n## 마무리\n\n{$human['outro']}\n";
|
|
|
|
$bodies = [
|
|
'models' => "| 새 |\n|---|\n| 표 |",
|
|
'tables' => '새 테이블 표',
|
|
'enums' => '문서에 마커가 없는 블록 — 주입되면 안 된다',
|
|
];
|
|
|
|
$result = ExtensionDocScaffolder::replaceBlocks($document, $bodies);
|
|
|
|
foreach ($human as $key => $text) {
|
|
$this->assertStringContainsString($text, $result['content'], "사람 서술 손실: {$key}");
|
|
}
|
|
|
|
$this->assertStringContainsString('| 새 |', $result['content']);
|
|
$this->assertStringNotContainsString('| 옛 |', $result['content']);
|
|
$this->assertStringContainsString('새 테이블 표', $result['content']);
|
|
$this->assertStringNotContainsString('옛 테이블 표', $result['content']);
|
|
|
|
$this->assertStringNotContainsString(
|
|
'주입되면 안 된다',
|
|
$result['content'],
|
|
'문서에 마커가 없는 블록은 임의 위치에 주입하지 않는다 (누락으로 보고).',
|
|
);
|
|
$this->assertSame(['enums'], $result['missing']);
|
|
$this->assertSame(['models', 'tables'], $result['replaced']);
|
|
|
|
// 멱등: 같은 본문으로 다시 돌리면 한 글자도 바뀌지 않아야 한다.
|
|
$again = ExtensionDocScaffolder::replaceBlocks($result['content'], [
|
|
'models' => $bodies['models'],
|
|
'tables' => $bodies['tables'],
|
|
]);
|
|
$this->assertTrue($again['unchanged'], '재실행이 멱등이 아닙니다.');
|
|
}
|
|
|
|
/**
|
|
* 미채움 마커 목록이 5종으로 고정되고 서로 겹치지 않아야 합니다.
|
|
*
|
|
* 이 목록은 자동 생성 블록이 채우지 못하는 사람 서술 자리를 가리킵니다. 항목이
|
|
* 늘거나 줄면 문서 완비 판정의 모집단이 조용히 바뀝니다.
|
|
*
|
|
* 내부 검사 도구(`check-extension-docs.cjs` · 미채움 audit 룰)가 같은 목록을
|
|
* 쓰는지는 그 도구들과 함께 있는 하네스 테스트가 대조합니다.
|
|
*/
|
|
public function test_todo_marker_list_is_fixed_and_distinct(): void
|
|
{
|
|
$markers = ExtensionDocScaffolder::todoMarkers();
|
|
|
|
$this->assertCount(5, $markers, '미채움 마커는 5종으로 고정입니다.');
|
|
$this->assertSame(
|
|
$markers,
|
|
array_values(array_unique($markers)),
|
|
'미채움 마커가 중복되면 그 자리는 두 번 세어집니다.',
|
|
);
|
|
|
|
foreach ($markers as $marker) {
|
|
$this->assertStringStartsWith(
|
|
'TODO: ',
|
|
$marker,
|
|
"마커 '{$marker}' 가 공통 접두를 잃으면 잔량 검사가 그 자리를 놓칩니다.",
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 빈 서술 축이 마커 잔량과 별개로 세어져야 합니다.
|
|
*
|
|
* 미채움은 두 축입니다 — `TODO:` 마커 잔량과, 마커를 지우고 서술을 쓰지 않은 빈
|
|
* `@intent` 블록. 후자를 세지 않으면 스캐폴딩 직후 마커만 지운 문서가 골격·블록
|
|
* 검사를 전부 통과해, 비어 있는 문서가 완비로 집계됩니다.
|
|
*
|
|
* 내부 검사 도구가 같은 수를 세는지는 그 도구와 함께 있는 하네스 테스트가 대조합니다.
|
|
*/
|
|
public function test_empty_intent_blocks_are_counted(): void
|
|
{
|
|
$filled = '<!-- @intent START -->
|
|
서술이 있다.
|
|
<!-- @intent END -->';
|
|
$empty = '<!-- @intent START -->
|
|
|
|
<!-- @intent END -->';
|
|
|
|
$this->assertSame(0, ExtensionDocScaffolder::emptyIntentBlocks($filled));
|
|
$this->assertSame(1, ExtensionDocScaffolder::emptyIntentBlocks($empty));
|
|
$this->assertSame(2, ExtensionDocScaffolder::emptyIntentBlocks($empty.'
|
|
'.$empty));
|
|
$this->assertSame(1, ExtensionDocScaffolder::emptyIntentBlocks($filled.'
|
|
'.$empty));
|
|
}
|
|
|
|
/**
|
|
* 모든 번들 확장에서 수집기와 렌더러가 예외 없이 동작해야 합니다.
|
|
*
|
|
* 확장 하나의 특이 구조(다국어 배열 라벨 등)가 생성 전체를 중단시키는 것을 막습니다.
|
|
*/
|
|
public function test_every_extension_renders_without_error(): void
|
|
{
|
|
$inventory = new ExtensionInventory;
|
|
$scaffolder = new ExtensionDocScaffolder;
|
|
|
|
foreach ($inventory->collect('all') as $record) {
|
|
$label = $record['type'].'/'.$record['id'];
|
|
$ctx = $this->contextFor($record, $inventory);
|
|
|
|
$this->assertSame(
|
|
[],
|
|
$ctx['surface']['errors'],
|
|
"{$label}: 선언형 표면 수집 중 실패한 getter 가 있습니다.",
|
|
);
|
|
|
|
// 수집 **실패** 는 errors 에 남지 않는다 — 진입 클래스 로드·인스턴스화 실패는
|
|
// 조기 반환이라 errors 가 빈 배열인 채 available=false 가 된다. errors 만
|
|
// 단언하면 모듈 20개의 표면이 전부 죽어도 이 테스트는 초록이고, 그 사이 문서에는
|
|
// "확인하지 못했습니다" 가 대량으로 기록된다. 템플릿은 선언형 표면을 갖지 않는
|
|
// 것이 정상이므로 대상에서 뺀다.
|
|
if ($record['type'] !== ExtensionInventory::TYPE_TEMPLATE) {
|
|
$this->assertTrue(
|
|
$ctx['surface']['available'],
|
|
"{$label}: 선언형 표면을 읽지 못했습니다 — ".($ctx['surface']['reason'] ?? '사유 미기록'),
|
|
);
|
|
}
|
|
|
|
$blocks = $scaffolder->renderBlocks($ctx);
|
|
$this->assertNotEmpty($blocks, "{$label}: 렌더된 블록이 없습니다.");
|
|
|
|
foreach ($blocks as $key => $body) {
|
|
$this->assertIsString($body, "{$label}: 블록 '{$key}' 본문이 문자열이 아닙니다.");
|
|
$this->assertNotSame('', trim($body), "{$label}: 블록 '{$key}' 본문이 비었습니다.");
|
|
}
|
|
|
|
foreach (ExtensionDocScaffolder::documentsForType($record['type']) as $doc) {
|
|
$skeleton = $scaffolder->skeleton($doc, $ctx);
|
|
$this->assertNotSame('', trim($skeleton), "{$label}: {$doc} 골격이 비었습니다.");
|
|
|
|
foreach (ExtensionDocScaffolder::sectionsFor($doc, $record['type']) as $section) {
|
|
$this->assertTrue(
|
|
ExtensionDocScaffolder::hasSection($skeleton, $section),
|
|
"{$label}: {$doc} 골격에 섹션 '{$section}' **헤딩**이 없습니다.",
|
|
);
|
|
}
|
|
|
|
foreach (ExtensionDocScaffolder::blocksFor($doc) as $block) {
|
|
$this->assertContains(
|
|
$block,
|
|
ExtensionDocScaffolder::presentBlockKeys($skeleton),
|
|
"{$label}: {$doc} 골격에 블록 '{$block}' 마커가 없습니다.",
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 섹션 판정이 낱말 등장이 아니라 헤딩을 봅니다.
|
|
*
|
|
* 절 이름을 부분문자열로 찾으면 이 축은 실패할 수 없습니다. README 는 자동 생성
|
|
* 인라인 목차가 12개 절 이름을 전부 담고, `docs/data-model.md` 의 모델 표는 헤더에
|
|
* `| 모델 | 테이블 |` 을 싣습니다 — 헤딩을 통째로 지워도 통과합니다. 실측 선례:
|
|
* `sirsoft-gdpr/README.md` 는 `## 변경 이력` 헤딩이 없는데 본문에 그 낱말이 3회
|
|
* 등장한다는 이유로 "섹션 있음" 으로 판정됐습니다.
|
|
*/
|
|
public function test_section_detection_requires_a_heading_not_a_word(): void
|
|
{
|
|
$section = '변경 이력';
|
|
|
|
// 헤딩 없이 낱말만 있는 형태 — 인라인 목차 · 표 헤더 · 산문
|
|
$decoys = [
|
|
"[소개](#소개) · [{$section}](#변경-이력) · [라이선스](#라이선스)\n",
|
|
"| 항목 | {$section} |\n|---|---|\n| a | b |\n",
|
|
"회원/게스트의 모든 동의 {$section}을 조회할 수 있습니다.\n",
|
|
"`## {$section}` 처럼 적으면 됩니다.\n",
|
|
];
|
|
|
|
foreach ($decoys as $i => $decoy) {
|
|
$this->assertFalse(
|
|
ExtensionDocScaffolder::hasSection($decoy, $section),
|
|
"낱말 등장(#{$i})이 섹션 존재로 판정됐습니다 — 헤딩을 지워도 통과하게 됩니다.",
|
|
);
|
|
}
|
|
|
|
foreach (["## {$section}\n", "### {$section}\n", "본문\n\n## {$section} \n\n다음"] as $i => $real) {
|
|
$this->assertTrue(
|
|
ExtensionDocScaffolder::hasSection($real, $section),
|
|
"실제 헤딩(#{$i})을 섹션 없음으로 판정했습니다.",
|
|
);
|
|
}
|
|
|
|
// 접두 일치로 다른 절을 인정하지 않는다 (`문서` 가 `문서 목차` 를 먹으면 안 된다).
|
|
$this->assertFalse(ExtensionDocScaffolder::hasSection("## 문서 목차\n", '문서'));
|
|
$this->assertTrue(ExtensionDocScaffolder::hasSection("## 문서 목차\n", '문서 목차'));
|
|
}
|
|
|
|
/**
|
|
* 골격의 절 아래에 **그 절의 블록**이 놓이는지 단언합니다.
|
|
*
|
|
* 헤딩 존재와 블록 마커 존재를 각각만 보면 짝이 어긋나도 전부 초록입니다 — 절을
|
|
* 하나 끼우는 순간 그 뒤 블록이 통째로 밀려 엉뚱한 헤딩 밑에 박히는데, 그 상태가
|
|
* 어떤 게이트에도 걸리지 않습니다.
|
|
*/
|
|
public function test_skeleton_places_each_block_under_its_own_section(): void
|
|
{
|
|
$inventory = new ExtensionInventory;
|
|
$scaffolder = new ExtensionDocScaffolder;
|
|
|
|
foreach ($inventory->collect('all') as $record) {
|
|
$ctx = $this->contextFor($record, $inventory);
|
|
|
|
foreach (ExtensionDocScaffolder::documentsForType($record['type']) as $doc) {
|
|
if (! ExtensionDocScaffolder::pairsSectionsWithBlocks($doc)) {
|
|
continue;
|
|
}
|
|
|
|
$skeleton = $scaffolder->skeleton($doc, $ctx);
|
|
$overrides = ExtensionDocScaffolder::DOCUMENTS[$doc]['sectionOverrides'][$record['type']] ?? [];
|
|
|
|
foreach (ExtensionDocScaffolder::DOCUMENTS[$doc]['blocks'] as $section => $blockKey) {
|
|
$heading = '## '.($overrides[$section] ?? $section);
|
|
$headingPos = mb_strpos($skeleton, $heading);
|
|
$blockPos = mb_strpos($skeleton, ExtensionDocScaffolder::GEN_PREFIX.$blockKey.' START');
|
|
|
|
$this->assertNotFalse($headingPos, "{$record['id']}: {$doc} 에 '{$heading}' 헤딩이 없습니다.");
|
|
$this->assertNotFalse($blockPos, "{$record['id']}: {$doc} 에 '{$blockKey}' 블록이 없습니다.");
|
|
$this->assertGreaterThan(
|
|
$headingPos,
|
|
$blockPos,
|
|
"{$record['id']}: {$doc} 의 '{$blockKey}' 블록이 '{$heading}' 절 아래에 있지 않습니다.",
|
|
);
|
|
|
|
// 다음 절이 시작되기 전에 그 블록이 나와야 한다 (다른 절로 밀리지 않음).
|
|
$nextHeading = mb_strpos($skeleton, '
|
|
## ', $headingPos + mb_strlen($heading));
|
|
if ($nextHeading !== false) {
|
|
$this->assertLessThan(
|
|
$nextHeading,
|
|
$blockPos,
|
|
"{$record['id']}: {$doc} 의 '{$blockKey}' 블록이 다음 절로 밀려 있습니다.",
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 수집 실패 통지가 실제로 방출되는 블록마다 실려야 합니다.
|
|
*
|
|
* 수집 실패는 수치가 아니라 문장으로 옵니다. 통지는 둘(표면 전체 실패 · 개별 getter
|
|
* 실패)이고, 어느 한쪽이 다른 어구로 시작하면 그 문서는 "발행 훅 N종 …" 같은 거짓
|
|
* 문장을 경고 없이 싣습니다.
|
|
*
|
|
* 그 어구와 집계 블록 마커를 내부 인덱스 스캐너가 그대로 알고 있는지는 그 스캐너와
|
|
* 함께 있는 하네스 테스트가 대조합니다.
|
|
*/
|
|
public function test_surface_notice_reaches_every_measured_block(): void
|
|
{
|
|
// 집계 블록은 마커로 감싸여 방출된다 — 스캐너가 그 구간을 잘라 수치를 읽는다.
|
|
$wrapped = ExtensionDocScaffolder::wrap('stats', '**훅 수**: 1');
|
|
|
|
$this->assertStringContainsString('@generated:stats START', $wrapped);
|
|
$this->assertStringContainsString('@generated:stats END', $wrapped);
|
|
|
|
$marker = ExtensionDocScaffolder::SURFACE_NOTICE_MARKER;
|
|
|
|
$inventory = new ExtensionInventory;
|
|
$record = $inventory->find('module', 'sirsoft-board');
|
|
|
|
$this->assertNotNull($record, '대조 기준 확장(sirsoft-board)이 없습니다.');
|
|
|
|
$ctx = $this->contextFor($record, $inventory);
|
|
$scaffolder = new ExtensionDocScaffolder;
|
|
|
|
// (1) 개별 getter 실패 — 본문은 살리고 사유를 덧붙이는 통지
|
|
$partial = $ctx;
|
|
$partial['surface']['errors'] = ['getRoutes' => '테스트 주입'];
|
|
|
|
foreach (['stats', 'hooks-published', 'hooks-subscribed', 'permissions'] as $key) {
|
|
$this->assertStringContainsString(
|
|
$marker,
|
|
$scaffolder->renderBlock($key, $partial),
|
|
"개별 getter 실패 시 `{$key}` 블록에 판정 어구가 실려야 인덱스가 그 수치를 점검 불가로 가릅니다.",
|
|
);
|
|
}
|
|
|
|
// (2) 표면 전체 실패 — 본문을 대체하는 통지
|
|
$whole = $ctx;
|
|
$whole['surface']['available'] = false;
|
|
$whole['surface']['reason'] = '테스트 주입';
|
|
$whole['surface']['errors'] = [];
|
|
|
|
// 본문을 대체하는 블록(permissions 등)뿐 아니라, 읽어낸 사실을 함께 싣는 블록
|
|
// (수치·훅 표)에도 붙어야 한다. 그 셋만 통지 대상에서 빠져 있으면 진입 클래스를
|
|
// 통째로 못 읽은 확장의 문서가 "발행 훅 N종 … 이 중 N종은 선언에 없어 자동 감지"
|
|
// 라는 거짓 문장을 경고 없이 싣고, 인덱스 스캐너는 그 수치를 실측으로 옮긴다.
|
|
foreach (['permissions', 'stats', 'hooks-published', 'hooks-subscribed'] as $key) {
|
|
$this->assertStringContainsString(
|
|
$marker,
|
|
$scaffolder->renderBlock($key, $whole),
|
|
"표면 전체 실패 시 `{$key}` 블록에도 같은 판정 어구가 실려야 합니다.",
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 세지 못한 지표가 0 으로 굳지 않는지 단언합니다.
|
|
*
|
|
* 수집기는 셀 수 없는 지표를 `null` 로 올립니다("0 을 돌려주면 주소가 없다는 사실
|
|
* 주장이 된다"). 그 계약은 소비처가 지키지 않으면 그 자리에서 끝납니다 — 배지가
|
|
* `?? 0` 으로 받으면 수집기가 구분해 올린 값이 다시 0 이 되고, 템플릿은 표면 실패
|
|
* 통지 대상이 아니라 단서도 남지 않아 인덱스가 그 0 을 실측으로 옮깁니다.
|
|
*/
|
|
public function test_unmeasured_stats_do_not_collapse_to_zero(): void
|
|
{
|
|
$inventory = new ExtensionInventory;
|
|
$record = $inventory->find('template', 'sirsoft-basic');
|
|
|
|
$this->assertNotNull($record, '대조 기준 템플릿(sirsoft-basic)이 없습니다.');
|
|
|
|
$ctx = $this->contextFor($record, $inventory);
|
|
|
|
// 정상 경로 — 실측이 그대로 실린다.
|
|
$this->assertIsInt(
|
|
ExtensionDocScaffolder::statsOf($ctx)['라우트 수'],
|
|
'`routes.json` 을 읽은 템플릿은 주소 수가 정수여야 합니다.',
|
|
);
|
|
|
|
// 세지 못한 경로 — null 이 0 으로 바뀌지 않아야 한다.
|
|
$unmeasured = $ctx;
|
|
$unmeasured['frontend']['routeCount'] = null;
|
|
|
|
$this->assertNull(
|
|
ExtensionDocScaffolder::statsOf($unmeasured)['라우트 수'],
|
|
'세지 못한 지표를 0 으로 받으면 "주소가 없다" 는 사실 주장이 됩니다.',
|
|
);
|
|
|
|
$block = (new ExtensionDocScaffolder)->renderBlock('stats', $unmeasured);
|
|
|
|
$this->assertStringContainsString(
|
|
ExtensionDocScaffolder::STAT_UNMEASURED,
|
|
$block,
|
|
'세지 못한 지표는 수치 자리에 그 사실이 드러나야 합니다.',
|
|
);
|
|
$this->assertStringNotContainsString(
|
|
'**라우트 수**: 0',
|
|
$block,
|
|
'세지 못한 주소 수가 0 으로 실리면 안 됩니다.',
|
|
);
|
|
$this->assertStringContainsString(
|
|
ExtensionDocScaffolder::SURFACE_NOTICE_MARKER,
|
|
$block,
|
|
'단서가 없으면 인덱스 스캐너가 이 블록을 실측으로 읽습니다.',
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 상세 문서로 거는 앵커가 그 문서의 실제 절 이름인지 단언합니다.
|
|
*
|
|
* 요약 표는 절 이름을 문자열로 다시 적어 앵커를 만듭니다. 절 이름을 바꾸면 헤딩
|
|
* 검사는 통과하는데 요약의 링크만 조용히 끊깁니다 — 앵커 유효성을 보는 장치가
|
|
* 없었습니다.
|
|
*/
|
|
public function test_summary_anchors_point_at_real_sections(): void
|
|
{
|
|
$source = (string) file_get_contents(
|
|
(string) (new \ReflectionClass(ExtensionDocScaffolder::class))->getFileName()
|
|
);
|
|
|
|
preg_match_all(
|
|
'/docLink\(\$ctx,\s*\'([^\']+)\',\s*\'([^\']+)\'\)/u',
|
|
$source,
|
|
$matches,
|
|
PREG_SET_ORDER
|
|
);
|
|
|
|
$this->assertNotEmpty($matches, 'docLink 호출을 하나도 찾지 못했습니다 — 판정식이 낡았습니다.');
|
|
|
|
foreach ($matches as [, $doc, $anchor]) {
|
|
$known = [];
|
|
foreach (['module', 'plugin', 'template'] as $type) {
|
|
$known = array_merge($known, ExtensionDocScaffolder::sectionsFor($doc, $type));
|
|
}
|
|
|
|
$this->assertContains(
|
|
$anchor,
|
|
$known,
|
|
"docLink 가 '{$doc}' 의 '{$anchor}' 절을 가리키는데 그런 절이 없습니다 — 링크가 끊깁니다.",
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 템플릿 유형은 API·모델·훅 문서를 요구하지 않아야 합니다.
|
|
*
|
|
* 유형별 골격 분기가 사라지면 템플릿에 채울 수 없는 문서가 요구됩니다.
|
|
*/
|
|
public function test_document_set_differs_by_extension_type(): void
|
|
{
|
|
$module = ExtensionDocScaffolder::documentsForType(ExtensionInventory::TYPE_MODULE);
|
|
$template = ExtensionDocScaffolder::documentsForType(ExtensionInventory::TYPE_TEMPLATE);
|
|
|
|
// `docs/editor-spec.md` 는 세 유형 공통이다. 편집기 스펙을 두지 않는 확장에도 문서를
|
|
// 두는 것은 "왜 없어도 되는가 / 언제 필요해지는가" 를 적을 자리가 필요하기 때문이며,
|
|
// 그 자리가 사라지면 미보유가 누락으로 오해되거나 필요한 시점을 놓친다.
|
|
foreach (['AGENTS.md', 'README.md', 'docs/README.md', 'docs/architecture.md', 'docs/editor-spec.md'] as $shared) {
|
|
$this->assertContains($shared, $module);
|
|
$this->assertContains($shared, $template);
|
|
}
|
|
|
|
$this->assertContains('docs/data-model.md', $module);
|
|
$this->assertContains('docs/extension-points.md', $module);
|
|
$this->assertNotContains('docs/data-model.md', $template);
|
|
$this->assertNotContains('docs/extension-points.md', $template);
|
|
|
|
$this->assertContains('docs/components.md', $template);
|
|
$this->assertContains('docs/layouts.md', $template);
|
|
$this->assertNotContains('docs/components.md', $module);
|
|
}
|
|
|
|
/**
|
|
* 고아 블록(문서에 실재하지만 필수 목록 밖) 검출이 살아 있는지 단언합니다.
|
|
*
|
|
* 이 축은 저장소에 문서가 0세트인 동안 실측이 항상 0건이라, 검출부가 깨져도
|
|
* `--check` 이슈 수만 줄고 아무 게이트도 붉어지지 않습니다. 검출식을 직접 겨눕니다.
|
|
*/
|
|
public function test_orphan_block_detection_is_alive(): void
|
|
{
|
|
$doc = 'AGENTS.md';
|
|
$known = ExtensionDocScaffolder::blocksFor($doc);
|
|
|
|
$this->assertNotEmpty($known, "{$doc} 의 필수 블록 목록이 비었습니다 — 모집단이 없습니다.");
|
|
|
|
$legit = $known[0];
|
|
$content = "# 제목\n\n"
|
|
.ExtensionDocScaffolder::wrap($legit, '본문')."\n\n"
|
|
.ExtensionDocScaffolder::wrap('legacy-orphan', '낡은 실측')."\n";
|
|
|
|
$present = ExtensionDocScaffolder::presentBlockKeys($content);
|
|
|
|
$this->assertContains($legit, $present, '필수 블록을 찾지 못했습니다.');
|
|
$this->assertContains(
|
|
'legacy-orphan',
|
|
$present,
|
|
'목록 밖 블록을 찾지 못하면 그 블록은 영영 갱신되지 않고 누락으로도 보고되지 않습니다.',
|
|
);
|
|
|
|
$orphans = array_values(array_filter(
|
|
$present,
|
|
static fn (string $key): bool => ! in_array($key, $known, true),
|
|
));
|
|
|
|
$this->assertSame(['legacy-orphan'], $orphans, '고아 판정이 필수 블록까지 잡거나 놓치고 있습니다.');
|
|
}
|
|
|
|
/**
|
|
* 유형별로 다른 표면을 생성기가 실제 파일 배치대로 서술하는지 단언합니다.
|
|
*
|
|
* 이 축의 결함은 예외도 경고도 남기지 않습니다 — 없는 파일을 요구하거나, 있는
|
|
* 디렉토리를 통째로 빠뜨린 문서가 조용히 커밋됩니다. 그 문서는 골격에 한 번만
|
|
* 쓰이고 자동 생성 블록이 아니라 재생성으로 고쳐지지도 않으므로, 틀린 채로 굳습니다.
|
|
*/
|
|
public function test_skeleton_matches_actual_extension_layout(): void
|
|
{
|
|
$inventory = new ExtensionInventory;
|
|
$scaffolder = new ExtensionDocScaffolder;
|
|
$records = $inventory->collect();
|
|
|
|
$this->assertNotEmpty($records, '확장을 하나도 찾지 못했습니다 — 모집단이 비었습니다.');
|
|
|
|
$checkedTemplate = 0;
|
|
$checkedControllers = 0;
|
|
|
|
foreach ($records as $record) {
|
|
$ctx = $this->contextFor($record, $inventory);
|
|
$agents = $scaffolder->skeleton('AGENTS.md', $ctx);
|
|
|
|
if ($record['type'] === ExtensionInventory::TYPE_TEMPLATE) {
|
|
$checkedTemplate++;
|
|
|
|
// 번들 템플릿은 PHP 패키지가 아니라 composer.json 을 갖지 않는다.
|
|
$this->assertFileDoesNotExist(
|
|
$record['path'].DIRECTORY_SEPARATOR.'composer.json',
|
|
"전제가 깨졌습니다: {$record['id']} 에 composer.json 이 생겼습니다.",
|
|
);
|
|
|
|
$this->assertStringNotContainsString(
|
|
'`composer.json` 동기화',
|
|
$agents,
|
|
"{$record['id']}: 템플릿에 없는 composer.json 동기화를 동반 의무로 요구하고 있습니다.",
|
|
);
|
|
|
|
// 주소는 routes.json 에 있다 — 선언형 표면만 보면 구조적으로 항상 0 이다.
|
|
$declared = $ctx['frontend']['routeCount'] ?? null;
|
|
|
|
if (is_file($record['path'].DIRECTORY_SEPARATOR.'routes.json')) {
|
|
$this->assertIsInt(
|
|
$declared,
|
|
"{$record['id']}: routes.json 이 있는데 주소 수를 세지 못했습니다.",
|
|
);
|
|
$this->assertSame(
|
|
$declared,
|
|
ExtensionDocScaffolder::statsOf($ctx)['라우트 수'],
|
|
"{$record['id']}: 집계 배지의 라우트 수가 routes.json 실측과 다릅니다.",
|
|
);
|
|
}
|
|
}
|
|
|
|
// 컨트롤러 자리는 두 갈래(`src/Http/Controllers/` · `src/Controllers/`)다.
|
|
// 실재하는 갈래가 디렉토리 지도에 나타나지 않으면 그 확장 문서에서
|
|
// "API 표면 변경 시 api:docgen 재실행" 절차가 통째로 사라진다.
|
|
foreach (['src/Http/Controllers', 'src/Controllers'] as $candidate) {
|
|
if (! is_dir($record['path'].DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $candidate))) {
|
|
continue;
|
|
}
|
|
|
|
$checkedControllers++;
|
|
|
|
$this->assertStringContainsString(
|
|
$candidate.'/',
|
|
$agents,
|
|
"{$record['id']}: {$candidate}/ 가 실재하는데 디렉토리 지도에 없습니다.",
|
|
);
|
|
}
|
|
|
|
// 다른 확장 화면에 주입하는 레이아웃 조각도 유형마다 자리가 다르다.
|
|
$extRoot = $record['type'] === ExtensionInventory::TYPE_TEMPLATE
|
|
? 'extensions'
|
|
: 'resources/extensions';
|
|
|
|
if (is_dir($record['path'].DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $extRoot))) {
|
|
$this->assertNotEmpty(
|
|
$ctx['frontend']['layoutExtensions'],
|
|
"{$record['id']}: {$extRoot}/ 가 실재하는데 레이아웃 확장 조각이 수집되지 않았습니다.",
|
|
);
|
|
|
|
// getLayoutExtensions() 기본 구현은 위와 같은 디렉토리를 glob() 한 절대경로라
|
|
// 파일 목록과 100% 중복이다. 정규화 없이 실으면 같은 파일이 두 번(상대·절대) 나오고
|
|
// 로컬 머신 절대경로가 커밋되는 확장 저장소에 그대로 남는다.
|
|
// 템플릿은 'layout-extensions' 가 아니라 'template-overrides' 블록(docs/layouts.md)이다
|
|
// — 그 개념(오버라이드)이 모듈/플러그인의 발행과 반대 방향이라 문서 자리가 다르다.
|
|
$blockKey = $record['type'] === ExtensionInventory::TYPE_TEMPLATE ? 'template-overrides' : 'layout-extensions';
|
|
$block = $scaffolder->renderBlocks($ctx)[$blockKey] ?? '';
|
|
$this->assertNotSame('', $block, "{$record['id']}: {$blockKey} 블록이 렌더되지 않았습니다.");
|
|
$this->assertStringNotContainsString(
|
|
str_replace('\\', '/', $record['path']),
|
|
str_replace('\\', '/', $block),
|
|
"{$record['id']}: 레이아웃 확장 표에 로컬 절대경로가 남아 있습니다.",
|
|
);
|
|
$rowCount = substr_count($block, "\n| `");
|
|
$this->assertSame(
|
|
count($ctx['frontend']['layoutExtensions']),
|
|
$rowCount,
|
|
"{$record['id']}: 레이아웃 확장 표 행 수가 실제 파일 수와 다릅니다 (중복 행 의심).",
|
|
);
|
|
}
|
|
|
|
// getNotificationDefinitions() 의 표준 계약은 리스트 배열 + 원소별 'type' 키다
|
|
// (NotificationSyncHelper::sync() 소비 형태). 'key'/'event' 로 읽으면 정수 인덱스
|
|
// 배열에서 이름을 못 찾아 모든 행이 '-' 로 찍힌다 — 표가 있으나 마나 해진다.
|
|
$declaredNotifications = $ctx['surface']['values']['getNotificationDefinitions'] ?? [];
|
|
if (is_array($declaredNotifications) && $declaredNotifications !== []) {
|
|
$notificationsBlock = $scaffolder->renderBlocks($ctx)['notifications'] ?? '';
|
|
$this->assertStringNotContainsString(
|
|
"\n| `-` |",
|
|
$notificationsBlock,
|
|
"{$record['id']}: 알림 정의 표의 알림 키가 비어 있습니다 ('type' 필드 매핑 확인).",
|
|
);
|
|
}
|
|
|
|
// getSettingsLayout() 은 getModulePath()/getPluginPath() 기준 절대경로를 돌려준다
|
|
// (§레이아웃 확장과 같은 결함군) — relativeToExtension() 을 거치지 않으면 로컬 머신
|
|
// 절대경로가 그대로 커밋 문서(docs/settings.md · README.md)에 실린다.
|
|
$settingsLayout = $ctx['surface']['values']['getSettingsLayout'] ?? null;
|
|
if (is_string($settingsLayout) && $settingsLayout !== '') {
|
|
$rendered = $scaffolder->renderBlocks($ctx);
|
|
// record['path'] 는 Symfony Finder 산출물(슬래시)과 코드 조립 문자열(백슬래시)이
|
|
// 섞인 혼합 구분자다 — 양쪽을 슬래시로 정규화하지 않으면 정규화된 needle 이
|
|
// 정규화되지 않은 haystack 안의 진짜 누출을 놓친다.
|
|
$needle = str_replace('\\', '/', $record['path']);
|
|
foreach (['settings-schema', 'settings-summary'] as $settingsBlockKey) {
|
|
$this->assertStringNotContainsString(
|
|
$needle,
|
|
str_replace('\\', '/', $rendered[$settingsBlockKey] ?? ''),
|
|
"{$record['id']}: {$settingsBlockKey} 블록에 로컬 절대경로가 남아 있습니다.",
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
$this->assertGreaterThan(0, $checkedTemplate, '템플릿을 하나도 검사하지 않았습니다.');
|
|
$this->assertGreaterThan(0, $checkedControllers, '컨트롤러 디렉토리를 하나도 검사하지 않았습니다.');
|
|
}
|
|
|
|
/**
|
|
* 스케줄 주기가 계약 키(`schedule`)에서 읽히는지 단언합니다.
|
|
*
|
|
* `getSchedules()` 의 표준 계약 키는 `schedule` 이다 — `AbstractModule`/`AbstractPlugin`
|
|
* 의 주석과 `routes/console.php` 의 실제 소비부가 그 SSoT 다. 렌더러가 다른 키
|
|
* (`expression`/`cron`/`frequency`)만 보면 주기 열이 **모든 확장에서 영구히 `-`** 가
|
|
* 되는데, 그 모양은 "주기를 선언하지 않았다" 와 구분되지 않아 아무도 이상을 알아채지
|
|
* 못한다. 실제로 그 상태로 board·ecommerce 두 확장이 문서화되었다.
|
|
*
|
|
* 확장 문서를 대조하지 않고 합성 입력으로 판정하는 이유는, 스케줄을 선언한 확장이
|
|
* 저장소에서 사라지면 실측 대조가 **공허 통과**하기 때문이다.
|
|
*/
|
|
public function test_schedule_period_reads_the_contract_key(): void
|
|
{
|
|
$ctx = [
|
|
'record' => ['type' => 'module', 'id' => 'vendor-ext'],
|
|
'surface' => [
|
|
'available' => true,
|
|
'values' => [
|
|
'getSchedules' => [
|
|
[
|
|
'command' => 'vendor-ext:do-something',
|
|
'schedule' => 'hourly',
|
|
'description' => '계약 키만 선언한 스케줄',
|
|
],
|
|
],
|
|
],
|
|
],
|
|
];
|
|
|
|
$block = (new ExtensionDocScaffolder)->renderBlock('schedules', $ctx);
|
|
|
|
$this->assertStringContainsString(
|
|
'hourly',
|
|
$block,
|
|
'계약 키 `schedule` 로 선언한 주기가 표에 실리지 않습니다 — 주기 열이 모든 확장에서 `-` 가 됩니다.',
|
|
);
|
|
$this->assertStringNotContainsString(
|
|
'| `-` |',
|
|
$block,
|
|
'주기를 선언한 스케줄이 미선언으로 렌더되었습니다.',
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 네임스페이스를 붙인 핸들러 등록 키가 수집·렌더 양쪽에서 살아남는지 단언합니다.
|
|
*
|
|
* `'vendor-ext.doThing': handler` 처럼 네임스페이스를 붙인 키는 `.`·`-` 때문에 반드시
|
|
* 따옴표로 감싸인다. 수집기가 식별자 키만 보면 그 항목이 통째로 빠지는데, 결과가
|
|
* "그만큼만 등록했다" 와 같은 모양이라 누락이 드러나지 않는다 — 실제로 sirsoft-basic 이
|
|
* 32개 중 10개를 그렇게 잃고 있었다.
|
|
*
|
|
* 렌더 축도 함께 잠근다. 템플릿은 namespace 가 null 이지만 네임스페이스를 붙여 등록하는
|
|
* 핸들러를 함께 가질 수 있어, 그 경우 "네임스페이스 없음" 으로 적으면 사실과 반대가 된다.
|
|
*/
|
|
public function test_namespaced_handler_keys_survive_collection_and_render(): void
|
|
{
|
|
$source = <<<'TS'
|
|
export const handlerMap = {
|
|
plainOne: plainOneHandler,
|
|
'vendor-ext.doThing': doThingHandler,
|
|
"vendor-ext.doOther": doOtherHandler,
|
|
};
|
|
TS;
|
|
|
|
$inventory = new FrontendInventory;
|
|
$method = (new \ReflectionClass(FrontendInventory::class))->getMethod('objectKeys');
|
|
$method->setAccessible(true);
|
|
|
|
/** @var array<int, string> $keys */
|
|
$keys = $method->invoke($inventory, $source, 'handlerMap');
|
|
|
|
$this->assertSame(
|
|
['plainOne', 'vendor-ext.doThing', 'vendor-ext.doOther'],
|
|
$keys,
|
|
'따옴표로 감싼 네임스페이스 등록 키가 수집에서 빠졌습니다 — 누락이 "그만큼만 등록했다" 로 보입니다.',
|
|
);
|
|
|
|
$block = (new ExtensionDocScaffolder)->renderBlock('handlers', [
|
|
'record' => ['type' => 'template', 'id' => 'vendor-ext'],
|
|
'surface' => ['available' => true, 'values' => []],
|
|
'frontend' => [
|
|
'handlers' => [
|
|
'namespace' => null,
|
|
'names' => $keys,
|
|
'source' => 'src/handlers/index.ts',
|
|
],
|
|
],
|
|
]);
|
|
|
|
$this->assertStringContainsString(
|
|
'핸들러 3개',
|
|
$block,
|
|
'수집한 핸들러 수가 표에 그대로 실려야 합니다.',
|
|
);
|
|
$this->assertStringContainsString(
|
|
'`vendor-ext.doThing`',
|
|
$block,
|
|
'네임스페이스를 붙여 등록한 핸들러는 그 전체 이름이 호출 이름입니다.',
|
|
);
|
|
$this->assertStringContainsString(
|
|
'`plainOne` | (템플릿 전용',
|
|
$block,
|
|
'네임스페이스 없이 등록한 핸들러는 기존 표기를 유지해야 합니다.',
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 확장 README 의 첫 화면이 이미지 배지가 아니라 H1 제목인지 단언합니다.
|
|
*
|
|
* PO 결정(2026-08-31): 확장 README 상단의 확장명은 shields.io 이미지 배지가 아니라 평범한
|
|
* H1 마크다운 제목이다. 확장은 20개가 병렬로 존재하는 대등한 구성요소이고, 각자가 코어와
|
|
* 같은 히어로 브랜딩을 받으면 "이 확장이 곧 독립 프로젝트" 라는 착시를 준다.
|
|
*
|
|
* 모집단은 손으로 적지 않고 `_bundled` 스캔에서 파생한다. 실제로 S2 가 이 축을
|
|
* `height="120"` 잔존 0 으로 닫았는데, S3 이 `height="60"` 으로 쓴 11개가 그 모집단 밖이라
|
|
* 게이트가 초록인 채 결정이 절반만 적용된 상태로 남았다. 판정은 높이가 아니라 **형태**
|
|
* (`style=for-the-badge` 이미지가 상단에 있는가)로 한다.
|
|
*
|
|
* `@generated:badges` 블록 안의 version/type/G7/license 배지는 대상이 아니다 — 그것은
|
|
* manifest 에서 오는 flat-square 정보 배지이고 계획서 §1.3 이 유지하기로 한 것이다.
|
|
*/
|
|
public function test_extension_readme_leads_with_a_heading_not_a_hero_badge(): void
|
|
{
|
|
$records = (new ExtensionInventory)->collect('all');
|
|
$this->assertNotEmpty($records, '번들 확장을 하나도 발견하지 못했습니다.');
|
|
|
|
$checked = 0;
|
|
$heroes = [];
|
|
$missingHeading = [];
|
|
|
|
foreach ($records as $record) {
|
|
$readme = $record['path'].'/README.md';
|
|
if (! is_file($readme)) {
|
|
continue;
|
|
}
|
|
|
|
$checked++;
|
|
$body = (string) file_get_contents($readme);
|
|
|
|
// 자동 생성 배지 블록 앞부분(사람이 쓰는 히어로 영역)만 본다.
|
|
$head = $body;
|
|
$blockAt = strpos($body, '<!-- @generated:badges START');
|
|
if ($blockAt !== false) {
|
|
$head = substr($body, 0, $blockAt);
|
|
}
|
|
|
|
if (str_contains($head, 'style=for-the-badge')) {
|
|
$heroes[] = $record['type'].'/'.$record['id'];
|
|
}
|
|
|
|
$firstLine = '';
|
|
foreach (preg_split('/\r?\n/', $head) ?: [] as $line) {
|
|
if (trim($line) !== '') {
|
|
$firstLine = trim($line);
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (! str_starts_with($firstLine, '# ')) {
|
|
$missingHeading[] = $record['type'].'/'.$record['id'].' — 첫 줄이 "'.$firstLine.'"';
|
|
}
|
|
}
|
|
|
|
$this->assertGreaterThan(
|
|
0,
|
|
$checked,
|
|
'README 를 가진 번들 확장이 하나도 없습니다 — 모집단이 비면 이 단언은 공허 통과합니다.',
|
|
);
|
|
|
|
$this->assertSame(
|
|
[],
|
|
$heroes,
|
|
"확장 README 상단에 히어로 이미지 배지가 남아 있습니다 (H1 제목으로 바꾸세요):\n".implode("\n", $heroes),
|
|
);
|
|
|
|
$this->assertSame(
|
|
[],
|
|
$missingHeading,
|
|
"확장 README 의 첫 줄이 H1 제목이 아닙니다:\n".implode("\n", $missingHeading),
|
|
);
|
|
|
|
// 이미 놓인 파일만 보면 **다음 확장**이 사각이다 — 골격 생성기가 히어로를 계속
|
|
// 찍어내면 21번째 확장은 태어나는 순간 이 단언에 걸리는 README 를 갖는다. 결정이
|
|
// 파일에만 적용되고 그 파일을 만드는 자리에는 적용되지 않은 상태가 남지 않도록
|
|
// 생성기 출력도 같은 판정을 통과시킨다.
|
|
$inventory = new ExtensionInventory;
|
|
$scaffolder = new ExtensionDocScaffolder;
|
|
|
|
foreach ($records as $record) {
|
|
$skeleton = $scaffolder->skeleton('README.md', $this->contextFor($record, $inventory));
|
|
$head = $skeleton;
|
|
$blockAt = strpos($skeleton, '<!-- @generated:badges START');
|
|
if ($blockAt !== false) {
|
|
$head = substr($skeleton, 0, $blockAt);
|
|
}
|
|
|
|
$this->assertStringNotContainsString(
|
|
'style=for-the-badge',
|
|
$head,
|
|
"README 골격 생성기가 히어로 배지를 찍어냅니다 ({$record['id']} 기준).",
|
|
);
|
|
$this->assertStringStartsWith(
|
|
'# ',
|
|
$skeleton,
|
|
"README 골격 생성기의 첫 줄이 H1 제목이 아닙니다 ({$record['id']} 기준).",
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 진입 문서 3종의 제목이 「그누보드7 {확장명} {유형}」 을 담는지 단언합니다.
|
|
*
|
|
* PO 결정(2026-09-04): 확장 README 제목이 확장명뿐(`# 게시판`)이면 그 문서만 연 사람이
|
|
* 이것이 그누보드7의 확장인지, 모듈인지 템플릿인지 알 수 없다. README · AGENTS.md ·
|
|
* docs/README.md 는 제3자가 처음 여는 진입 문서이므로 셋 다 같은 제목을 쓴다.
|
|
*
|
|
* 기대값은 손으로 적지 않고 생성기와 같은 헬퍼(`ExtensionInventory::docTitle`)에서
|
|
* 만든다 — 규칙을 두 곳에 적으면 한쪽만 고쳐 어긋난다. 골격 생성기 출력도 같은 판정을
|
|
* 받아, 다음 확장이 확장명만 든 제목으로 태어나는 경로를 막는다.
|
|
*/
|
|
public function test_entry_documents_carry_product_and_type_in_their_title(): void
|
|
{
|
|
$inventory = new ExtensionInventory;
|
|
$records = $inventory->collect('all');
|
|
$this->assertNotEmpty($records, '번들 확장을 하나도 발견하지 못했습니다.');
|
|
|
|
$expectedFor = fn (array $record): array => [
|
|
'README.md' => '# '.ExtensionInventory::docTitle($record['name'], $record['type']),
|
|
'AGENTS.md' => '# '.ExtensionInventory::docTitle($record['name'], $record['type']).' — 에이전트 가이드',
|
|
'docs/README.md' => '# '.ExtensionInventory::docTitle($record['name'], $record['type']).' 개발자 문서',
|
|
];
|
|
|
|
$checked = 0;
|
|
$mismatched = [];
|
|
|
|
foreach ($records as $record) {
|
|
foreach ($expectedFor($record) as $rel => $heading) {
|
|
$file = $record['path'].'/'.$rel;
|
|
if (! is_file($file)) {
|
|
continue;
|
|
}
|
|
|
|
$checked++;
|
|
$firstLine = strtok((string) file_get_contents($file), "\r\n");
|
|
|
|
if ($firstLine !== $heading) {
|
|
$mismatched[] = "{$record['relPath']}/{$rel}: \"{$firstLine}\" (기대: \"{$heading}\")";
|
|
}
|
|
}
|
|
}
|
|
|
|
$this->assertGreaterThan(
|
|
0,
|
|
$checked,
|
|
'진입 문서를 가진 번들 확장이 하나도 없습니다 — 모집단이 비면 이 단언은 공허 통과합니다.',
|
|
);
|
|
$this->assertSame(
|
|
[],
|
|
$mismatched,
|
|
"진입 문서 제목이 「그누보드7 {확장명} {유형}」 형식이 아닙니다:\n".implode("\n", $mismatched),
|
|
);
|
|
|
|
// 이미 놓인 파일만 보면 다음 확장이 사각이다 — 골격 생성기 출력도 같은 판정을 받는다.
|
|
$scaffolder = new ExtensionDocScaffolder;
|
|
|
|
foreach ($records as $record) {
|
|
$ctx = $this->contextFor($record, $inventory);
|
|
|
|
foreach ($expectedFor($record) as $rel => $heading) {
|
|
$skeleton = $scaffolder->skeleton($rel, $ctx);
|
|
$this->assertSame(
|
|
$heading,
|
|
strtok($skeleton, "\r\n"),
|
|
"골격 생성기의 {$rel} 제목이 형식을 따르지 않습니다 ({$record['id']} 기준).",
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 확장 문서의 「활동 로그 훅」 표가 리스너의 실제 구독과 일치하는지 단언합니다.
|
|
*
|
|
* 이 목록은 코어 `docs/backend/activity-log-hooks.md` 에서 확장 소유로 옮겨온 것이고(#601),
|
|
* 옮겨올 당시 코어 문서가 이미 낡아 있었다. 리스너가 `before_*` 훅 구독을 걷어내고
|
|
* **Service 가 스냅샷을 `after_*` 인자로 넘기는** 구조로 바뀌었는데 표는 그대로였다 —
|
|
* 없는 구독 31행이 실려 있었고 실제 구독 13건이 빠져 있었으며, 소계·총계·메서드 이름까지
|
|
* 어긋나 있었다. 문서가 확장점의 SSoT 이므로 이 어긋남은 그 확장을 잡으려는 쪽이
|
|
* **잡히지 않는 훅을 구독**하게 만든다 (예외도 경고도 없이 리스너가 안 불릴 뿐이다).
|
|
*
|
|
* 모집단은 손으로 적지 않고 **코드**에서 파생한다 — `logActivity` 를 호출하면서 훅을
|
|
* 구독하는 리스너를 가진 확장 전부가 대상이다. 문서의 「활동 로그 훅」 절 존재 여부로
|
|
* 모집단을 정하면 그 절을 통째로 빠뜨린 확장이 검사에서 조용히 빠지는 순환이 된다
|
|
* (결제 3종이 실제로 그 사각에 있었다 — 리스너는 있는데 절이 없어 검사 밖이었다).
|
|
*
|
|
* 대상 판정을 파일명 관례(`*ActivityLog*`)가 아니라 실제 `logActivity` 호출로 하는 이유도
|
|
* 같다. 관례를 벗어난 이름도 잡히고, 설명 변수 해석기(`ActivityLogDescriptionResolver` —
|
|
* 기록하지 않고 해석만 한다)는 클래스명을 손으로 열거하지 않아도 빠진다.
|
|
*/
|
|
public function test_activity_log_hook_tables_match_listener_subscriptions(): void
|
|
{
|
|
$records = (new ExtensionInventory)->collect('all');
|
|
$this->assertNotEmpty($records, '번들 확장을 하나도 발견하지 못했습니다.');
|
|
|
|
$checked = 0;
|
|
$problems = [];
|
|
|
|
foreach ($records as $record) {
|
|
// 모집단은 **코드**에서 파생한다. 「활동 로그 훅」 절을 가진 확장만 보면 절을
|
|
// 통째로 빠뜨린 확장이 검사에서 조용히 빠진다 — 검사 대상 문서의 존재 여부로
|
|
// 모집단을 정하는 순환이다(실제로 결제 3종이 그 사각에 있었다).
|
|
$listeners = glob($record['path'].'/src/Listeners/*.php') ?: [];
|
|
$subscriptions = [];
|
|
|
|
foreach ($listeners as $file) {
|
|
// 활동 로그를 **기록하는** 리스너만 대상이다. 파일명 관례(`*ActivityLog*`)가
|
|
// 아니라 실제 `logActivity` 호출로 가른다 — 관례를 벗어난 이름도 잡히고,
|
|
// 설명 해석기(기록하지 않고 해석만 한다)는 클래스명을 열거하지 않아도 빠진다.
|
|
if (! str_contains((string) file_get_contents($file), 'logActivity')) {
|
|
continue;
|
|
}
|
|
|
|
$fqcn = $this->listenerFqcn($record, basename($file, '.php'));
|
|
if ($fqcn === null || ! method_exists($fqcn, 'getSubscribedHooks')) {
|
|
continue;
|
|
}
|
|
|
|
foreach (array_keys($fqcn::getSubscribedHooks()) as $hook) {
|
|
$subscriptions[] = $hook;
|
|
}
|
|
}
|
|
|
|
if ($subscriptions === []) {
|
|
continue;
|
|
}
|
|
|
|
$checked++;
|
|
|
|
$key = $record['type'].'/'.$record['id'];
|
|
$doc = $record['path'].'/docs/extension-points.md';
|
|
$body = is_file($doc) ? (string) file_get_contents($doc) : '';
|
|
$at = strpos($body, '## 활동 로그 훅');
|
|
|
|
if ($at === false) {
|
|
$problems[] = $key.' — 활동 로그를 기록하는 리스너가 있으나 docs/extension-points.md 에 「활동 로그 훅」 절이 없습니다 (구독 '.count($subscriptions).'건).';
|
|
|
|
continue;
|
|
}
|
|
|
|
// 절은 **다음 `## ` 헤딩에서 끊는다.** 파일 끝까지 열어 두면 뒤따르는 절
|
|
// (「훅 리스너」·「미들웨어」 등)의 표까지 삼켜 그 행이 전부 유령으로 보고된다.
|
|
$section = substr($body, $at);
|
|
$nextHeading = preg_match('/^## /m', substr($section, 3), $m, PREG_OFFSET_CAPTURE) === 1
|
|
? 3 + $m[0][1]
|
|
: strlen($section);
|
|
$section = substr($section, 0, $nextHeading);
|
|
|
|
preg_match_all('/^\|\s*`([^`]+)`/m', $section, $matches);
|
|
$documented = $matches[1];
|
|
|
|
$phantom = array_values(array_unique(array_diff($documented, $subscriptions)));
|
|
$missing = array_values(array_unique(array_diff($subscriptions, $documented)));
|
|
|
|
foreach ($phantom as $hook) {
|
|
$problems[] = $key.' — 표에 있으나 구독하지 않습니다: '.$hook;
|
|
}
|
|
foreach ($missing as $hook) {
|
|
$problems[] = $key.' — 구독하지만 표에 없습니다: '.$hook;
|
|
}
|
|
if (count($documented) !== count($subscriptions)) {
|
|
$problems[] = $key.' — 표의 행 수('.count($documented).')와 구독 등록 수('.count($subscriptions).')가 다릅니다.';
|
|
}
|
|
}
|
|
|
|
$this->assertGreaterThan(
|
|
0,
|
|
$checked,
|
|
'활동 로그를 기록하는 리스너를 가진 확장이 하나도 없습니다 — 모집단이 비면 이 단언은 공허 통과합니다.',
|
|
);
|
|
|
|
$this->assertSame(
|
|
[],
|
|
$problems,
|
|
"활동 로그 훅 표가 리스너의 실제 구독과 어긋납니다:\n".implode("\n", $problems),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 확장 리스너 클래스의 FQCN 을 조립합니다.
|
|
*
|
|
* @param array<string, mixed> $record 확장 레코드
|
|
* @param string $class 리스너 클래스명
|
|
* @return string|null 존재하는 FQCN, 없으면 null
|
|
*/
|
|
private function listenerFqcn(array $record, string $class): ?string
|
|
{
|
|
$root = $record['type'] === 'plugin' ? 'Plugins' : 'Modules';
|
|
|
|
[$vendor, $name] = array_pad(explode('-', (string) $record['id'], 2), 2, '');
|
|
$studly = static fn (string $v): string => str_replace(' ', '', ucwords(str_replace(['-', '_'], ' ', $v)));
|
|
|
|
$sep = chr(92);
|
|
$fqcn = $root.$sep.$studly($vendor).$sep.$studly($name).$sep.'Listeners'.$sep.$class;
|
|
|
|
return class_exists($fqcn) ? $fqcn : null;
|
|
}
|
|
|
|
/**
|
|
* 수집 컨텍스트를 만듭니다.
|
|
*
|
|
* @param array<string, mixed> $record 확장 레코드
|
|
* @param ExtensionInventory $inventory 인벤토리 (역방향 의존 스캔에 사용)
|
|
* @return array<string, mixed> 수집 컨텍스트
|
|
*/
|
|
private function contextFor(array $record, ExtensionInventory $inventory): array
|
|
{
|
|
// 커맨드와 **같은 조립기**를 쓴다. 여기서 배열을 손으로 다시 엮으면 수집 축이 하나
|
|
// 늘 때 한쪽만 갱신되어, 테스트가 만든 블록에 그 축이 빠진 채 파일과 달라진다 —
|
|
// 결과는 "생성기를 다시 돌려라" 인데 아무리 돌려도 사라지지 않는 드리프트다.
|
|
return app(ExtensionDocContext::class)->build($record);
|
|
}
|
|
|
|
/**
|
|
* 문서가 싣는 Playwright 명령이 그 확장의 config 를 잡는지 단언합니다.
|
|
*
|
|
* 확장은 자기 config 를 `tests/Playwright/playwright.config.ts` 에 두므로, 저장소
|
|
* 루트에서 spec 경로만 넘기면 코어 config(`testDir: tests/Playwright/specs`)가 잡혀
|
|
* 그 spec 이 모집단 밖이 됩니다. 결과는 실패가 아니라 **"No tests found"** 입니다 —
|
|
* 문서대로 따른 사람은 테스트를 돌렸다고 믿는데 0건이 지나가고, 코어 globalSetup 이
|
|
* 개발 사이트에 시드 화면을 설치·제거하는 부작용만 남습니다.
|
|
*
|
|
* 모집단은 열거하지 않고 실측에서 파생합니다 — Playwright 테스트를 새로 갖추는 확장이
|
|
* 생기면 자동으로 편입됩니다.
|
|
*/
|
|
public function test_playwright_command_resolves_the_extension_config(): void
|
|
{
|
|
$collector = new TestPathCollector;
|
|
$checked = [];
|
|
|
|
foreach ((new ExtensionInventory)->collect('all') as $record) {
|
|
$collected = $collector->collect($record);
|
|
|
|
// 명령 문자열이 아니라 **라벨**로 고른다 — 명령은 `npm run test:e2e` 처럼
|
|
// 도구 이름을 담지 않을 수 있고, 그러면 이 검사가 대상을 못 찾은 채 통과한다.
|
|
$command = null;
|
|
foreach ($collected['commands'] as $entry) {
|
|
if (str_contains($entry['label'], 'Playwright')) {
|
|
$command = $entry['command'];
|
|
break;
|
|
}
|
|
}
|
|
|
|
if ($command === null) {
|
|
$this->assertSame(
|
|
0,
|
|
$collected['playwright']['count'],
|
|
"{$record['type']}:{$record['id']} — Playwright 테스트가 있는데 실행 명령이 없습니다.",
|
|
);
|
|
|
|
continue;
|
|
}
|
|
|
|
$rel = $record['relPath'];
|
|
$config = $record['path'].DIRECTORY_SEPARATOR.'tests'.DIRECTORY_SEPARATOR
|
|
.'Playwright'.DIRECTORY_SEPARATOR.'playwright.config.ts';
|
|
|
|
$this->assertFileExists(
|
|
$config,
|
|
"{$rel} — Playwright 명령을 싣는 확장은 자기 config 를 가져야 합니다.",
|
|
);
|
|
|
|
// 확장 디렉토리로 이동하거나(그 자리의 npm 스크립트가 --config 를 품는다),
|
|
// 명령이 직접 --config 를 지목하거나. 둘 중 하나여야 코어 config 를 피한다.
|
|
$entersExtensionDir = str_contains($command, "cd {$rel} &&");
|
|
$namesConfig = str_contains($command, '--config=');
|
|
|
|
$this->assertTrue(
|
|
$entersExtensionDir || $namesConfig,
|
|
"{$rel} — 명령이 코어 config 를 잡습니다(실행 결과가 \"No tests found\"). "
|
|
."확장 디렉토리로 이동하거나 --config 를 지목해야 합니다: {$command}",
|
|
);
|
|
|
|
$this->assertStringNotContainsString(
|
|
"npx playwright test {$rel}/tests/Playwright/specs/",
|
|
$command,
|
|
"{$rel} — 루트 기준 spec 경로 형태는 코어 config 로 해석됩니다.",
|
|
);
|
|
|
|
$checked[] = $rel;
|
|
}
|
|
|
|
// 하한 — 모집단이 비면 위 루프가 통째로 돌지 않고도 통과한다.
|
|
$this->assertNotEmpty(
|
|
$checked,
|
|
'Playwright 명령을 싣는 확장을 하나도 찾지 못했습니다 — 모집단 파생이 죽었습니다.',
|
|
);
|
|
}
|
|
}
|