fix(fortune): scope s5s fortune markup (#1150)

* fix(markdown): scope fortune markup to fortune boards

* fix(fortune): store fortune output as bbcode

* fix(fortune): keep legacy fortune rendering

* fix(tests): resolve catalog button mock merge

* fix(fortune): validate hidden fortune length
This commit is contained in:
Tommaso Casaburi
2026-06-03 13:49:35 +07:00
committed by GitHub
parent 84f357cba1
commit d09d2d05d1
16 changed files with 439 additions and 93 deletions
@@ -458,6 +458,12 @@ const waitForOptionsValidation = async () => {
});
};
const waitForContentLengthValidation = async () => {
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 1020));
});
};
const dispatchChange = async (element: HTMLInputElement | HTMLSelectElement, value: string | boolean) => {
await act(async () => {
if (typeof value === 'boolean' && 'checked' in element) {
@@ -932,7 +938,7 @@ describe('PostForm', () => {
});
});
it('validates unsupported options and stores fortune output in post content', async () => {
it('validates unsupported options and keeps fortune output out of preview state until post publish', async () => {
const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0.25);
testState.resolvedCommunityAddress = 'random-nsfw.bso';
@@ -963,14 +969,38 @@ describe('PostForm', () => {
await dispatchInput(optionsInput as HTMLInputElement, 'fortune');
expect(container.textContent).not.toContain('Unsupported options');
expect(testState.publishPostOptions.content).toBe('fortune body<span class="fortune" style="color:#fd4d32"><br><br><b>Your fortune: Excellent Luck</b></span>');
expect(testState.publishPostOptions.content).toBe('fortune body');
await clickByText(table as HTMLTableElement, 'post');
expect(testState.publishPostMock).toHaveBeenCalledTimes(1);
expect(testState.publishPostMock).toHaveBeenCalledWith({
content: 'fortune body[fortune color=#fd4d32]Excellent Luck[/fortune]',
});
randomSpy.mockRestore();
});
it('counts hidden fortune output in post length validation without revealing it', async () => {
testState.resolvedCommunityAddress = 'random-nsfw.bso';
await renderPostForm('/b');
await clickByText(container, 'start_new_thread');
const table = container.querySelector('table') as HTMLTableElement;
const optionsInput = table.querySelector<HTMLInputElement>('input[aria-label="options"]') as HTMLInputElement;
const textarea = table.querySelector<HTMLTextAreaElement>('textarea') as HTMLTextAreaElement;
const longContent = 'x'.repeat(1930);
await dispatchInput(optionsInput, 'fortune');
await dispatchInput(textarea, longContent);
await waitForContentLengthValidation();
expect(testState.publishPostOptions.content).toBe(longContent);
expect(container.textContent).toContain('comment_field_too_long');
expect(container.textContent).not.toContain('[fortune color=');
expect(testState.publishPostMock).not.toHaveBeenCalled();
});
it('supports fortune on the /s5s/ route when directory metadata is not loaded', async () => {
const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0.25);
testState.resolvedCommunityAddress = 'silly-stuff.bso';
@@ -992,7 +1022,8 @@ describe('PostForm', () => {
await clickByText(table as HTMLTableElement, 'post');
expect(testState.publishPostMock).toHaveBeenCalledTimes(1);
expect(testState.publishedPostOptions?.content).toBe('silly fortune<span class="fortune" style="color:#fd4d32"><br><br><b>Your fortune: Excellent Luck</b></span>');
expect(testState.publishedPostOptions?.content).toBe('silly fortune[fortune color=#fd4d32]Excellent Luck[/fortune]');
expect(testState.setPublishPostOptionsMock).toHaveBeenCalledWith({ content: 'silly fortune' });
randomSpy.mockRestore();
});
+5 -4
View File
@@ -15,6 +15,7 @@ import {
getContentWithPostOptionState as getContentWithOptions,
getNonokoPendingRouteState,
getPostOptionsDirectoryCode,
getPostOptionsPublishContentLength,
getPostOptionsValidationError,
hasNonokoOption,
isPostOptionsValidationError,
@@ -548,8 +549,8 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
const [bbcodePreviewContent, setBbcodePreviewContent] = useState('');
const checkContentLength = useRef(
debounce((content: string, t: TFunction) => {
const length = content.trim().length;
debounce((content: string, t: TFunction, options: string, directoryCode: string | undefined) => {
const length = getPostOptionsPublishContentLength(content, options, directoryCode);
if (length > 2000) {
setLengthError(`${t('error')}: ${t('comment_field_too_long', { length })}`);
} else {
@@ -694,7 +695,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
}, [checkContentLength, checkPostOptions, isInPostView, resetPublishPostOptions, resetPublishReplyOptions]);
const handleContentValueChange = (content: string, options = optionsRef.current?.value || '') => {
const publishContent = getContentWithOptions(content, options, fortuneEntryRef, diceRollRef, postOptionsDirectoryCode);
const publishContent = getContentWithOptions(content, options, fortuneEntryRef, diceRollRef, postOptionsDirectoryCode, { includeFortune: false });
if (isBbcodePreviewing) {
setBbcodePreviewContent(content);
}
@@ -703,7 +704,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
} else {
setPublishPostOptions({ content: publishContent });
}
checkContentLength(publishContent, t);
checkContentLength(publishContent, t, options, postOptionsDirectoryCode);
};
const handleContentChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {