Skip to content

Commit a1a49d8

Browse files
authored
Merge pull request #201 from ram0ng1/refatoracao-tokens-design-system
refactor: melhorar a extração de texto puro de postagens e adicionar testes unitários
2 parents f622e3b + 4209ae7 commit a1a49d8

2 files changed

Lines changed: 174 additions & 5 deletions

File tree

src/Api/DiscussionFields.php

Lines changed: 52 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
namespace Ramon\Avocado\Api;
66

7+
use DOMDocument;
8+
use DOMXPath;
79
use Flarum\Api\Context;
810
use Flarum\Api\Resource\EloquentBuffer;
911
use Flarum\Api\Schema;
@@ -12,7 +14,6 @@
1214
use Illuminate\Contracts\Filesystem\Factory as FilesystemFactory;
1315
use Illuminate\Contracts\Filesystem\Filesystem;
1416
use Ramon\Avocado\Model\DiscussionHero;
15-
use s9e\TextFormatter\Utils;
1617

1718
class DiscussionFields
1819
{
@@ -129,18 +130,64 @@ private function firstPost(Discussion $discussion, Context $context): ?CommentPo
129130
/**
130131
* Texto puro do primeiro post, na mesma forma que o front produzia com
131132
* `getPlainContent(contentHtml)`: sem citações e sem as URLs cruas das
132-
* imagens (que o `removeFormatting` devolveria como texto).
133+
* imagens (que viriam como texto no lugar da imagem).
133134
*/
134135
private function excerpt(?CommentPost $post): ?string
135136
{
136137
if ($post === null || empty($post->parsed_content)) {
137138
return null;
138139
}
139140

140-
$xml = preg_replace('#<(QUOTE|IMG|UPL-IMAGE-PREVIEW)\b.*?</\1>#is', '', $post->parsed_content) ?? $post->parsed_content;
141-
$xml = preg_replace('#<(IMG|UPL-IMAGE-PREVIEW)\b[^>]*/?>#i', '', $xml) ?? $xml;
141+
return $this->plainExcerpt($post->parsed_content);
142+
}
143+
144+
/**
145+
* Reduz a representação intermediária do s9e a texto corrido.
146+
*
147+
* O corte é feito no DOM, não por regex: um `<QUOTE>` aninhado fazia o
148+
* `.*?</QUOTE>` casar até o fechamento *interno*, deixando o externo órfão.
149+
* O XML resultante não carregava, `Utils::removeFormatting()` devolvia null
150+
* e o `preg_replace` seguinte derrubava o request inteiro com TypeError —
151+
* uma discussão com citação de citação quebrava a lista toda.
152+
*
153+
* Uma única passada de parse cobre o que era feito em quatro (dois
154+
* `preg_replace` mais o load do `removeFormatting`).
155+
*/
156+
private function plainExcerpt(string $xml): ?string
157+
{
158+
if (trim($xml) === '') {
159+
return null;
160+
}
161+
162+
$dom = new DOMDocument();
163+
164+
// XML de post é gerado pelo s9e e deveria sempre carregar; se um
165+
// registro antigo ou corrompido não carregar, o card fica sem descrição
166+
// em vez de estourar 500 — e o warning do libxml não vaza no output.
167+
$internal = libxml_use_internal_errors(true);
168+
$loaded = $dom->loadXML($xml, LIBXML_COMPACT | LIBXML_PARSEHUGE);
169+
libxml_clear_errors();
170+
libxml_use_internal_errors($internal);
171+
172+
if (! $loaded || $dom->documentElement === null) {
173+
return null;
174+
}
175+
176+
$xpath = new DOMXPath($dom);
177+
178+
// Citações e imagens saem inteiras, trocadas por um espaço: retirar o
179+
// nó sem separador colaria o texto vizinho ("antes<IMG/>depois").
180+
foreach ($xpath->query('//QUOTE | //IMG | //UPL-IMAGE-PREVIEW') as $node) {
181+
$node->parentNode?->replaceChild($dom->createTextNode(' '), $node);
182+
}
183+
184+
// `e`/`s` são a marcação em si (os `**` do negrito, o `>` da citação):
185+
// saem sem espaço, senão partiriam a palavra que estavam decorando.
186+
foreach ($xpath->query('//e | //s') as $node) {
187+
$node->parentNode?->removeChild($node);
188+
}
142189

143-
$plain = trim(preg_replace('/\s+/', ' ', Utils::removeFormatting($xml)) ?? '');
190+
$plain = trim(preg_replace('/\s+/', ' ', $dom->documentElement->textContent) ?? '');
144191

145192
return $plain === '' ? null : mb_substr($plain, 0, self::EXCERPT_LENGTH);
146193
}
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Ramon\Avocado\Tests\Unit;
6+
7+
use PHPUnit\Framework\Attributes\DataProvider;
8+
use PHPUnit\Framework\TestCase;
9+
use Ramon\Avocado\Api\DiscussionFields;
10+
use ReflectionClass;
11+
12+
/**
13+
* `avocadoExcerpt` roda para cada linha de um payload de Index: um post cujo XML
14+
* o redutor não soubesse tratar derrubava a listagem inteira com 500, não só o
15+
* card daquela discussão. Estes testes fixam o contrato — texto corrido, sem
16+
* citação, sem imagem, e null (nunca exceção) para entrada estranha.
17+
*
18+
* `plainExcerpt()` é privado e puro (string → ?string), então é chamado por
19+
* reflexão: não precisa de container, disco nem modelo carregado.
20+
*/
21+
final class DiscussionExcerptTest extends TestCase
22+
{
23+
private static function excerpt(string $xml): ?string
24+
{
25+
$fields = (new ReflectionClass(DiscussionFields::class))->newInstanceWithoutConstructor();
26+
27+
$method = new \ReflectionMethod(DiscussionFields::class, 'plainExcerpt');
28+
29+
/** @var string|null $result */
30+
$result = $method->invoke($fields, $xml);
31+
32+
return $result;
33+
}
34+
35+
public function test_plain_text_post_is_returned_as_is(): void
36+
{
37+
self::assertSame('Bom dia a todos', self::excerpt('<t>Bom dia a todos</t>'));
38+
}
39+
40+
public function test_markup_tags_are_dropped_but_text_is_kept(): void
41+
{
42+
// <s>/<e> são os próprios asteriscos do negrito na representação do s9e.
43+
$xml = '<r>Isto é <STRONG><s>**</s>importante<e>**</e></STRONG> mesmo</r>';
44+
45+
self::assertSame('Isto é importante mesmo', self::excerpt($xml));
46+
}
47+
48+
public function test_quote_content_is_not_part_of_the_excerpt(): void
49+
{
50+
$xml = '<r><QUOTE><i>&gt; </i><p>texto citado</p></QUOTE><p>minha resposta</p></r>';
51+
52+
self::assertSame('minha resposta', self::excerpt($xml));
53+
}
54+
55+
/**
56+
* O caso que gerava o TypeError: o `.*?</QUOTE>` casava até o fechamento da
57+
* citação interna e deixava o `</QUOTE>` externo órfão, o XML resultante não
58+
* carregava e `removeFormatting()` devolvia null.
59+
*/
60+
public function test_nested_quotes_do_not_break_the_excerpt(): void
61+
{
62+
$xml = '<r><QUOTE><i>&gt; </i><QUOTE><i>&gt;&gt; </i><p>citação de dentro</p></QUOTE>'
63+
. '<p>citação de fora</p></QUOTE><p>resposta final</p></r>';
64+
65+
self::assertSame('resposta final', self::excerpt($xml));
66+
}
67+
68+
#[DataProvider('imageMarkup')]
69+
public function test_image_urls_never_leak_into_the_excerpt(string $image): void
70+
{
71+
$excerpt = self::excerpt('<r><p>antes</p>' . $image . '<p>depois</p></r>');
72+
73+
self::assertSame('antes depois', $excerpt);
74+
self::assertStringNotContainsString('http', (string) $excerpt);
75+
}
76+
77+
/** @return array<string, array{0: string}> */
78+
public static function imageMarkup(): array
79+
{
80+
return [
81+
'IMG vazio' => ['<IMG src="https://exemplo.test/foto.png">https://exemplo.test/foto.png</IMG>'],
82+
'IMG auto-fechado' => ['<IMG src="https://exemplo.test/foto.png"/>'],
83+
'anexo do fof-upload' => ['<UPL-IMAGE-PREVIEW url="https://exemplo.test/anexo.webp">https://exemplo.test/anexo.webp</UPL-IMAGE-PREVIEW>'],
84+
];
85+
}
86+
87+
public function test_whitespace_is_collapsed_into_single_spaces(): void
88+
{
89+
self::assertSame('linha um linha dois', self::excerpt("<t>linha um\n\n linha dois</t>"));
90+
}
91+
92+
public function test_excerpt_is_capped(): void
93+
{
94+
$excerpt = self::excerpt('<t>' . str_repeat('a', 500) . '</t>');
95+
96+
self::assertSame(300, mb_strlen((string) $excerpt));
97+
}
98+
99+
public function test_post_with_only_a_quote_has_no_excerpt(): void
100+
{
101+
$xml = '<r><QUOTE><i>&gt; </i><p>só a citação</p></QUOTE></r>';
102+
103+
self::assertNull(self::excerpt($xml));
104+
}
105+
106+
#[DataProvider('unusableContent')]
107+
public function test_content_that_cannot_be_parsed_yields_null(string $xml): void
108+
{
109+
self::assertNull(self::excerpt($xml));
110+
}
111+
112+
/** @return array<string, array{0: string}> */
113+
public static function unusableContent(): array
114+
{
115+
return [
116+
'vazio' => [''],
117+
'só espaços' => ['<t> </t>'],
118+
'tag desbalanceada' => ['<r><QUOTE><p>órfã</r>'],
119+
'não é XML' => ['isto não é xml <<< nem perto'],
120+
];
121+
}
122+
}

0 commit comments

Comments
 (0)