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