2026-09-23 15:02:33 +02:00
using System.Runtime.CompilerServices ;
2026-11-04 08:41:19 +01:00
using AIStudio.Chat ;
2026-09-23 15:02:33 +02:00
using AIStudio.Tools ;
2026-10-27 15:23:42 +01:00
using Markdig.Syntax ;
2026-09-23 15:02:33 +02:00
namespace AIStudio.Tests.Tools ;
/// <summary>
/// Checks which files the export menu finds in an answer.
/// </summary>
/// <remarks>
/// Asked for a web page, a model answers with a code block marked as html, and the same goes for a
/// LaTeX document or a Markdown text. That block already is the file the user wants. Converted along
/// with the rest of the answer, Pandoc shows it as a listing of source code instead, which is what
/// PR #993 reported. The fixture is the page attached to that PR, as the model wrote it.
/// </remarks>
[TestFixture]
public sealed class PlainFileExportTests
{
private static readonly string PAGE = ReadFixture ( "standalone_page.html" );
[Test]
public void AnAnswerMadeOfOneWebPageOffersThatPage ()
{
2026-11-04 08:41:19 +01:00
var files = PlainFileExport . ExtractFiles ( MarkdownRenderPlanner . Build ( Lines ( "```html" , PAGE , "```" )), ',' );
2026-09-23 15:02:33 +02:00
2026-11-01 20:32:16 +01:00
using ( Assert . EnterMultipleScope ())
2026-09-23 15:02:33 +02:00
{
Assert . That ( files , Has . Count . EqualTo ( 1 ));
Assert . That ( files [ 0 ]. Format , Is . EqualTo ( FileExportFormat . HTML ));
Assert . That ( files [ 0 ]. Content , Is . EqualTo ( PAGE ), "The page leaves the answer exactly as the model wrote it, without the fence around it." );
Assert . That ( files [ 0 ]. Caption , Is . Empty , "Without a heading above it, a code block has nothing to be named after." );
2026-11-01 20:32:16 +01:00
}
2026-09-23 15:02:33 +02:00
}
[Test]
public void AWebPageAmidExplanationsIsOfferedAsWell ()
{
var answer = Lines ( "Here is your page:" , string . Empty , "```html" , PAGE , "```" , string . Empty , "Save it and open it in your browser." );
2026-11-04 08:41:19 +01:00
var files = PlainFileExport . ExtractFiles ( MarkdownRenderPlanner . Build ( answer ), ',' );
2026-09-23 15:02:33 +02:00
2026-11-01 20:32:16 +01:00
using ( Assert . EnterMultipleScope ())
2026-09-23 15:02:33 +02:00
{
Assert . That ( files , Has . Count . EqualTo ( 1 ), "Models rarely answer with the block alone, so the text around it must not hide it." );
Assert . That ( files [ 0 ]. Content , Is . EqualTo ( PAGE ));
2026-11-01 20:32:16 +01:00
}
2026-09-23 15:02:33 +02:00
}
[TestCase("HTML", FileExportFormat.HTML)]
[TestCase("tex", FileExportFormat.LATEX)]
[TestCase("markdown", FileExportFormat.MARKDOWN)]
[TestCase("html title=\"index.html\"", FileExportFormat.HTML, Description = "Whatever follows the language is an argument, not part of it.")]
public void ACodeBlockIsOfferedInTheFormatItsLanguageNames ( string infoString , FileExportFormat expectedFormat )
{
2026-11-04 08:41:19 +01:00
var files = PlainFileExport . ExtractFiles ( MarkdownRenderPlanner . Build ( Lines ( $"```{infoString}" , "The content." , "```" )), ',' );
2026-09-23 15:02:33 +02:00
2026-11-01 20:32:16 +01:00
using ( Assert . EnterMultipleScope ())
2026-09-23 15:02:33 +02:00
{
Assert . That ( files , Has . Count . EqualTo ( 1 ));
Assert . That ( files [ 0 ]. Format , Is . EqualTo ( expectedFormat ));
Assert . That ( files [ 0 ]. Content , Is . EqualTo ( "The content." ));
2026-11-01 20:32:16 +01:00
}
2026-09-23 15:02:33 +02:00
}
[Test]
public void ATildeFenceIsOfferedAsWell ()
{
2026-11-04 08:41:19 +01:00
var files = PlainFileExport . ExtractFiles ( MarkdownRenderPlanner . Build ( Lines ( "~~~latex" , @"\section{Results}" , "~~~" )), ',' );
2026-09-23 15:02:33 +02:00
Assert . That ( files . Select ( file => file . Format ), Is . EqualTo ( new [] { FileExportFormat . LATEX }));
}
2026-10-27 15:23:42 +01:00
[TestCase("```python", "Python (.py)")]
[TestCase("```rust", "Rust (.rs)")]
[TestCase("```css", "CSS (.css)")]
[TestCase("~~~bash", "Shell (.sh)")]
[TestCase("```foo", "foo (.txt)", Description = "A language AI Studio does not know still names the block.")]
public void ACodeBlockInAnyOtherLanguageIsOfferedAsCode ( string openingFence , string expectedName )
2026-09-23 15:02:33 +02:00
{
2026-11-04 08:41:19 +01:00
var files = PlainFileExport . ExtractFiles ( MarkdownRenderPlanner . Build ( Lines ( openingFence , "print(42)" , openingFence [.. 3 ])), ',' );
2026-09-23 15:02:33 +02:00
2026-11-01 20:32:16 +01:00
using ( Assert . EnterMultipleScope ())
2026-10-27 15:23:42 +01:00
{
Assert . That ( files , Has . Count . EqualTo ( 1 ));
Assert . That ( files [ 0 ]. Format , Is . EqualTo ( FileExportFormat . SOURCE_CODE ));
Assert . That ( files [ 0 ]. ToName (), Is . EqualTo ( expectedName ));
Assert . That ( files [ 0 ]. Content , Is . EqualTo ( "print(42)" ), "The code leaves the answer exactly as the model wrote it, without the fence around it." );
2026-11-01 20:32:16 +01:00
}
2026-10-27 15:23:42 +01:00
}
2026-11-04 07:58:57 +01:00
[TestCase("using System;\n\nConsole.WriteLine(\"Hello\");", "C# (.cs)")]
[TestCase("def greet(name):\n print(f\"Hello {name}\")\n\nif __name__ == \"__main__\":\n greet(\"Ada\")", "Python (.py)")]
[TestCase("{\"name\": \"Ada\", \"age\": 36}", "JSON (.json)")]
[TestCase("Just a few words the model put into a block.", "Text (.txt)", Description = "A block whose language is not clear stays text.")]
public void ACodeBlockWithoutALanguageIsOfferedInTheLanguageOfItsCode ( string code , string expectedName )
{
2026-11-04 08:41:19 +01:00
var files = PlainFileExport . ExtractFiles ( MarkdownRenderPlanner . Build ( Lines ( "```" , code , "```" )), ',' );
2026-11-04 07:58:57 +01:00
using ( Assert . EnterMultipleScope ())
{
Assert . That ( files , Has . Count . EqualTo ( 1 ));
Assert . That ( files [ 0 ]. Format , Is . EqualTo ( FileExportFormat . SOURCE_CODE ));
Assert . That ( files [ 0 ]. ToName (), Is . EqualTo ( expectedName ));
Assert . That ( files [ 0 ]. Content , Is . EqualTo ( code ));
}
}
[Test]
public void ACodeBlockMarkedAsTextStaysText ()
{
2026-11-04 08:41:19 +01:00
var files = PlainFileExport . ExtractFiles ( MarkdownRenderPlanner . Build ( Lines ( "```text" , "using System;" , string . Empty , "Console.WriteLine(\"Hello\");" , "```" )), ',' );
2026-11-04 07:58:57 +01:00
Assert . That ( files . Select ( file => file . ToName ()), Is . EqualTo ( new [] { "Text (.txt)" }), "Whoever marks a block as text gets a text file, whatever its code looks like." );
}
[Test]
public void AWebPageWithoutALanguageIsOfferedAsAWebPage ()
{
2026-11-04 08:41:19 +01:00
var files = PlainFileExport . ExtractFiles ( MarkdownRenderPlanner . Build ( Lines ( "```" , "<div class=\"card\">" , " <p>Hello</p>" , "</div>" , "```" )), ',' );
2026-11-04 07:58:57 +01:00
Assert . That ( files . Select ( file => file . Format ), Is . EqualTo ( new [] { FileExportFormat . HTML }));
}
2026-11-04 08:41:19 +01:00
[TestCase("$$", "$$")]
[TestCase(@"\[", @"\] ")]
[TestCase("```latex", "```", Description = "A code block of LaTeX which holds nothing but a formula is a formula, too.")]
public void AFormulaIsOfferedAsLatex ( string openingMarker , string closingMarker )
{
var answer = Lines ( "The energy follows from" , string . Empty , openingMarker , "E = mc^2" , closingMarker , string . Empty , "for every body at rest." );
var files = PlainFileExport . ExtractFiles ( MarkdownRenderPlanner . Build ( answer ), ',' );
using ( Assert . EnterMultipleScope ())
{
Assert . That ( files , Has . Count . EqualTo ( 1 ));
Assert . That ( files [ 0 ]. ToName (), Is . EqualTo ( "LaTeX (.tex)" ));
Assert . That ( files [ 0 ]. Content , Is . EqualTo ( Lines ( @"\[" , "E = mc^2" , @"\]" )), "Between these markers, the formula can be pasted into a document as it is." );
Assert . That ( files [ 0 ]. Position , Is . EqualTo ( answer . IndexOf ( openingMarker , StringComparison . Ordinal )), "A formula finds its file where its markers start." );
}
}
2026-10-27 15:23:42 +01:00
[Test]
2026-11-04 08:41:19 +01:00
public void AFormulaIsCountedAmongTheCodeBlocks ()
2026-10-27 15:23:42 +01:00
{
2026-11-04 08:41:19 +01:00
var answer = Lines ( "# Energy" , string . Empty , "$$" , "E = mc^2" , "$$" , string . Empty , "```python" , "print(1)" , "```" );
2026-10-27 15:23:42 +01:00
2026-11-04 08:41:19 +01:00
var files = PlainFileExport . ExtractFiles ( MarkdownRenderPlanner . Build ( answer ), ',' );
2026-10-27 15:23:42 +01:00
2026-11-04 08:41:19 +01:00
Assert . That ( files . Select ( file => ( file . Ordinal , file . Caption , file . Format )), Is . EqualTo ( new []
{
( 1 , "Energy" , FileExportFormat . LATEX ),
( 2 , "Energy" , FileExportFormat . SOURCE_CODE ),
}));
2026-09-23 15:02:33 +02:00
}
[TestCase("```html", "<html><body><p>The answer broke off here", TestName = "Half a web page")]
[TestCase("```csv", "Quarter,Revenue", TestName = "Half a table")]
2026-10-27 15:23:42 +01:00
[TestCase("```python", "def main():", TestName = "Half a program")]
[TestCase("```", "The answer broke off here", TestName = "Half a text")]
2026-09-23 15:02:33 +02:00
public void ACodeBlockTheModelNeverClosedIsNotOffered ( string openingFence , string content )
{
2026-11-04 08:41:19 +01:00
var files = PlainFileExport . ExtractFiles ( MarkdownRenderPlanner . Build ( Lines ( "The answer starts normally." , string . Empty , openingFence , content )), ',' );
2026-09-23 15:02:33 +02:00
Assert . That ( files , Is . Empty , "The file would end wherever the answer broke off." );
}
[Test]
public void TablesAndCodeBlocksAreCountedApart ()
{
var answer = Lines (
"# Revenue" ,
string . Empty ,
"| Quarter | Revenue |" ,
"|---|---|" ,
"| Q1 | 100 |" ,
string . Empty ,
"# Landing page" ,
string . Empty ,
"```html" ,
"<p>First block</p>" ,
"```" ,
string . Empty ,
"```latex" ,
@"\section{Second block}" ,
2026-10-27 15:23:42 +01:00
"```" ,
string . Empty ,
"# Script" ,
string . Empty ,
"```python" ,
"print('Third block')" ,
2026-09-23 15:02:33 +02:00
"```" );
2026-11-04 08:41:19 +01:00
var files = PlainFileExport . ExtractFiles ( MarkdownRenderPlanner . Build ( answer ), ',' );
2026-09-23 15:02:33 +02:00
Assert . That ( files . Select ( file => ( file . Ordinal , file . Caption , file . Format )), Is . EqualTo ( new []
{
( 1 , "Revenue" , FileExportFormat . CSV ),
( 1 , "Landing page" , FileExportFormat . HTML ),
( 2 , "Landing page" , FileExportFormat . LATEX ),
2026-10-27 15:23:42 +01:00
( 3 , "Script" , FileExportFormat . SOURCE_CODE ),
}), "The first code block is code block 1, even though a table stands before it, and code counts along with the other blocks." );
}
[Test]
public void EachFileKnowsWhereItStartsInTheAnswer ()
{
var answer = Lines (
"Here is the revenue:" ,
string . Empty ,
"| Quarter | Revenue |" ,
"|---|---|" ,
"| Q1 | 100 |" ,
string . Empty ,
"And the script which made it:" ,
string . Empty ,
"```python" ,
"print(100)" ,
"```" );
2026-11-04 08:41:19 +01:00
var files = PlainFileExport . ExtractFiles ( MarkdownRenderPlanner . Build ( answer ), ',' );
2026-10-27 15:23:42 +01:00
Assert . That ( files . Select ( file => file . Position ), Is . EqualTo ( new [] { answer . IndexOf ( "| Quarter" , StringComparison . Ordinal ), answer . IndexOf ( "```python" , StringComparison . Ordinal ) }));
}
[Test]
public void ACodeBlockInAListKnowsWhereItStarts ()
{
var answer = Lines (
"1. Install the package:" ,
string . Empty ,
" ```bash" ,
" pip install requests" ,
" ```" ,
"2. Run your script." );
2026-11-04 08:41:19 +01:00
var files = PlainFileExport . ExtractFiles ( MarkdownRenderPlanner . Build ( answer ), ',' );
2026-10-27 15:23:42 +01:00
Assert . That ( files . Select ( file => file . Position ), Is . EqualTo ( new [] { answer . IndexOf ( "```bash" , StringComparison . Ordinal ) }));
}
/// <summary>
/// Checks what a code block the chat renders relies on to find its file.
/// </summary>
/// <remarks>
/// The chat renders an answer in parts, cut around each formula, and Markdig reads every part on
/// its own. A block of such a part therefore knows where it starts within its part only. Added to
/// where the part starts in the answer, that has to be the position the file was read with.
/// </remarks>
[Test]
public void ACodeBlockAfterAFormulaFindsItsFile ()
{
var answer = Lines (
"# Script" ,
string . Empty ,
"```python" ,
"print(1)" ,
"```" ,
string . Empty ,
"$$" ,
"E = mc^2" ,
"$$" ,
string . Empty ,
"```rust" ,
"fn main() {}" ,
"```" );
2026-11-04 08:41:19 +01:00
var files = PlainFileExport . ExtractFiles ( MarkdownRenderPlanner . Build ( answer ), ',' );
2026-10-27 15:23:42 +01:00
var partStart = answer . LastIndexOf ( "$$" , StringComparison . Ordinal ) + "$$" . Length + Environment . NewLine . Length ;
var part = Markdig . Markdown . Parse ( answer [ partStart ..], Markdown . CHAT_MARKDOWN_PIPELINE );
var blockOfThePart = part . Descendants < FencedCodeBlock >(). Single ();
2026-11-01 20:32:16 +01:00
using ( Assert . EnterMultipleScope ())
2026-10-27 15:23:42 +01:00
{
2026-11-04 08:41:19 +01:00
Assert . That ( files , Has . Count . EqualTo ( 3 ), "The formula between the two code blocks is a file, too." );
Assert . That ( partStart + blockOfThePart . Span . Start , Is . EqualTo ( files [ 2 ]. Position ));
Assert . That ( files [ 2 ]. ToName (), Is . EqualTo ( "Rust (.rs)" ));
2026-11-01 20:32:16 +01:00
}
2026-09-23 15:02:33 +02:00
}
private static string Lines ( params string [] lines ) => string . Join ( Environment . NewLine , lines );
/// <summary>
/// Reads a file from the fixtures next to this test.
/// </summary>
/// <remarks>
/// Read from the source tree, the way the capability snapshot is, so the fixture needs no entry in
/// the project file. A checkout on Windows may have turned its line ends into CRLF, which the
/// model never wrote.
/// </remarks>
private static string ReadFixture ( string fileName , [ CallerFilePath ] string sourceFilePath = "" ) => File
. ReadAllText ( Path . Combine ( Path . GetDirectoryName ( sourceFilePath )!, "Fixtures" , fileName ))
. Replace ( "\r\n" , "\n" );
}