Add WikiLinkResolver with deterministic 5-step resolution
Prefers current locale, then document default_locale, then any locale (lowest document_id), then slug match (legacy).
This commit is contained in:
@@ -0,0 +1,64 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Helpers\SlugHelper;
|
||||||
|
use App\Models\Document;
|
||||||
|
use App\Models\DocumentTranslation;
|
||||||
|
|
||||||
|
class WikiLinkResolver
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Resolve [[wiki-link]] text to a Document, preferring the current locale.
|
||||||
|
*
|
||||||
|
* Resolution order:
|
||||||
|
* 1. translations WHERE locale = $currentLocale AND title = $linkText
|
||||||
|
* 2. translations WHERE locale = document.default_locale AND title = $linkText
|
||||||
|
* 3. translations WHERE title = $linkText (lowest document_id wins)
|
||||||
|
* 4. documents WHERE slug = SlugHelper::generate($linkText)
|
||||||
|
* 5. null
|
||||||
|
*/
|
||||||
|
public function resolve(string $linkText, string $currentLocale): ?Document
|
||||||
|
{
|
||||||
|
$linkText = trim($linkText);
|
||||||
|
|
||||||
|
// 1. Current-locale exact title match
|
||||||
|
$byCurrent = DocumentTranslation::where('locale', $currentLocale)
|
||||||
|
->where('title', $linkText)
|
||||||
|
->orderBy('document_id')
|
||||||
|
->first();
|
||||||
|
if ($byCurrent) {
|
||||||
|
return $byCurrent->document;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Document's default-locale title match
|
||||||
|
$byDefault = DocumentTranslation::query()
|
||||||
|
->join('documents', 'documents.id', '=', 'document_translations.document_id')
|
||||||
|
->whereColumn('document_translations.locale', 'documents.default_locale')
|
||||||
|
->where('document_translations.title', $linkText)
|
||||||
|
->orderBy('document_translations.document_id')
|
||||||
|
->select('document_translations.*')
|
||||||
|
->first();
|
||||||
|
if ($byDefault) {
|
||||||
|
return $byDefault->document;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Any-locale title match (lowest document_id wins)
|
||||||
|
$byAny = DocumentTranslation::where('title', $linkText)
|
||||||
|
->orderBy('document_id')
|
||||||
|
->first();
|
||||||
|
if ($byAny) {
|
||||||
|
return $byAny->document;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Slug match (legacy)
|
||||||
|
$slug = SlugHelper::generate($linkText);
|
||||||
|
$bySlug = Document::where('slug', $slug)->first();
|
||||||
|
if ($bySlug) {
|
||||||
|
return $bySlug;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Nothing
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Unit\Services;
|
||||||
|
|
||||||
|
use App\Models\Document;
|
||||||
|
use App\Models\DocumentTranslation;
|
||||||
|
use App\Services\WikiLinkResolver;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class WikiLinkResolverTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
private WikiLinkResolver $resolver;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
$this->resolver = new WikiLinkResolver();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_resolves_via_current_locale_title(): void
|
||||||
|
{
|
||||||
|
$doc = Document::factory()->create(['default_locale' => 'en', 'slug' => 'getting-started']);
|
||||||
|
$doc->translations()->where('locale', 'en')->update(['title' => 'Getting Started']);
|
||||||
|
DocumentTranslation::factory()->create([
|
||||||
|
'document_id' => $doc->id,
|
||||||
|
'locale' => 'ja',
|
||||||
|
'title' => 'はじめに',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$resolved = $this->resolver->resolve('はじめに', 'ja');
|
||||||
|
$this->assertSame($doc->id, $resolved->id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_resolves_via_default_locale_when_current_locale_missing(): void
|
||||||
|
{
|
||||||
|
$doc = Document::factory()->create(['default_locale' => 'en']);
|
||||||
|
$doc->translations()->where('locale', 'en')->update(['title' => 'Getting Started']);
|
||||||
|
|
||||||
|
$resolved = $this->resolver->resolve('Getting Started', 'ja');
|
||||||
|
$this->assertSame($doc->id, $resolved->id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_resolves_via_any_locale_deterministically(): void
|
||||||
|
{
|
||||||
|
// Two documents both have a 'fr' translation titled "Bonjour", neither is current/default
|
||||||
|
$docA = Document::factory()->create(['default_locale' => 'en']);
|
||||||
|
DocumentTranslation::factory()->create(['document_id' => $docA->id, 'locale' => 'fr', 'title' => 'Bonjour']);
|
||||||
|
|
||||||
|
$docB = Document::factory()->create(['default_locale' => 'en']);
|
||||||
|
DocumentTranslation::factory()->create(['document_id' => $docB->id, 'locale' => 'fr', 'title' => 'Bonjour']);
|
||||||
|
|
||||||
|
$resolved = $this->resolver->resolve('Bonjour', 'ja');
|
||||||
|
// Lower id wins (deterministic)
|
||||||
|
$this->assertSame($docA->id, $resolved->id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_resolves_via_slug_when_no_title_match(): void
|
||||||
|
{
|
||||||
|
$doc = Document::factory()->create(['slug' => 'unique-slug', 'default_locale' => 'en']);
|
||||||
|
$doc->translations()->where('locale', 'en')->update(['title' => 'Whatever']);
|
||||||
|
|
||||||
|
$resolved = $this->resolver->resolve('unique-slug', 'ja');
|
||||||
|
$this->assertSame($doc->id, $resolved->id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_returns_null_when_nothing_matches(): void
|
||||||
|
{
|
||||||
|
$this->assertNull($this->resolver->resolve('Nonexistent', 'en'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_current_locale_wins_over_default(): void
|
||||||
|
{
|
||||||
|
// Doc A has en title "Setup"; Doc B has ja title "Setup"
|
||||||
|
$docA = Document::factory()->create(['default_locale' => 'en']);
|
||||||
|
$docA->translations()->where('locale', 'en')->update(['title' => 'Setup']);
|
||||||
|
|
||||||
|
$docB = Document::factory()->create(['default_locale' => 'en']);
|
||||||
|
$docB->translations()->where('locale', 'en')->update(['title' => 'Different']);
|
||||||
|
DocumentTranslation::factory()->create(['document_id' => $docB->id, 'locale' => 'ja', 'title' => 'Setup']);
|
||||||
|
|
||||||
|
// Browsing in ja: ja-locale match (Doc B) should win over default-locale match (Doc A)
|
||||||
|
$resolved = $this->resolver->resolve('Setup', 'ja');
|
||||||
|
$this->assertSame($docB->id, $resolved->id);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user