mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-09-27 01:33:37 +00:00
Find HTML, LaTeX, and Markdown code blocks in answers
This commit is contained in:
parent
5ce16f3f24
commit
2a6ae556eb
@ -133,6 +133,13 @@ public static class FileExportFormatExtensions
|
||||
return format is not FileExportFormat.NONE;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether the format holds a table rather than a text.
|
||||
/// </summary>
|
||||
/// <param name="format">The format.</param>
|
||||
/// <returns>True for the formats a spreadsheet opens.</returns>
|
||||
public static bool IsTabular(this FileExportFormat format) => format is FileExportFormat.CSV or FileExportFormat.TSV;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the file name the save dialog starts with.
|
||||
/// </summary>
|
||||
|
||||
@ -1,12 +1,14 @@
|
||||
namespace AIStudio.Tools;
|
||||
|
||||
/// <summary>
|
||||
/// A file found in a message, ready to be written: a table the model wrote.
|
||||
/// A file found in a message, ready to be written: a table the model wrote, or a code block the
|
||||
/// model marked as a format we write.
|
||||
/// </summary>
|
||||
/// <param name="Ordinal">Which table of the message this is, counting from one. The same table
|
||||
/// appears once per format we offer for it, so this is what tells two tables apart even when they
|
||||
/// carry the same heading.</param>
|
||||
/// <param name="Caption">What the table is about, taken from its first column heading.</param>
|
||||
/// <param name="Ordinal">Which table or which code block of the message this is, counting from one
|
||||
/// within its kind; the format tells the two kinds apart, see FileExportFormatExtensions.IsTabular.
|
||||
/// This is what tells two files of one kind apart even when they carry the same heading.</param>
|
||||
/// <param name="Caption">What the file is about: the heading above it, or else the first column
|
||||
/// heading of a table. Empty for a code block without a heading above it.</param>
|
||||
/// <param name="Format">The format this content is written as.</param>
|
||||
/// <param name="Content">The finished file content.</param>
|
||||
public sealed record MessageFile(int Ordinal, string Caption, FileExportFormat Format, string Content);
|
||||
@ -16,17 +16,20 @@ public static class PlainFileExport
|
||||
private static string TB(string fallbackEn) => I18N.I.T(fallbackEn, typeof(PlainFileExport).Namespace, nameof(PlainFileExport));
|
||||
|
||||
/// <summary>
|
||||
/// Reads every table a message holds, in the order they appear in it.
|
||||
/// Reads every file a message holds, in the order they appear in it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Two kinds of tables end up in an answer. Almost always it is a Markdown table written with
|
||||
/// pipes, which is what a model produces on its own; we turn its cells into a file. Rarely a
|
||||
/// model answers with a fenced code block marked as csv or tsv, which already is the finished
|
||||
/// file: we hand that through untouched rather than taking it apart and reassembling it.
|
||||
/// Two kinds of files end up in an answer. Almost always it is a Markdown table written with
|
||||
/// pipes, which is what a model produces on its own; we turn its cells into a file. Besides,
|
||||
/// a model answers with a fenced code block marked as a format we write, such as html, latex,
|
||||
/// markdown, or csv, whenever it was asked for a web page, a document, or data. Such a block
|
||||
/// already is the finished file: we hand it through untouched rather than taking it apart and
|
||||
/// reassembling it. We do not judge what the block holds, either. A browser shows a fragment of
|
||||
/// HTML just as well as an entire page, and a LaTeX fragment is still what the user asked for.
|
||||
/// </remarks>
|
||||
/// <param name="markdown">The Markdown text of the message.</param>
|
||||
/// <param name="separator">The separator to write a Markdown table with, see CsvWriter.SeparatorFor.</param>
|
||||
/// <returns>The tables, or an empty list when the message holds none.</returns>
|
||||
/// <returns>The files, or an empty list when the message holds none.</returns>
|
||||
public static IReadOnlyList<MessageFile> ExtractFiles(string markdown, char separator)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(markdown))
|
||||
@ -40,9 +43,10 @@ public static class PlainFileExport
|
||||
var document = Markdig.Markdown.Parse(markdown, Markdown.SAFE_MARKDOWN_PIPELINE);
|
||||
|
||||
//
|
||||
// What a table is about stands above it, not in it: models introduce their tables with a
|
||||
// heading. We remember every heading with its line so that each table can take the last
|
||||
// one before it, and fall back to its own first column heading when there is none.
|
||||
// What a file is about stands above it, not in it: models introduce their tables and code
|
||||
// blocks with a heading. We remember every heading with its line so that each file can take
|
||||
// the last one before it. A table falls back to its own first column heading when there is
|
||||
// none; a code block has nothing comparable and stays without a caption.
|
||||
//
|
||||
var headings = document.Descendants<HeadingBlock>()
|
||||
.Select(heading => (heading.Line, Text: ToPlainText(heading)))
|
||||
@ -56,11 +60,18 @@ public static class PlainFileExport
|
||||
var codeBlocks = document.Descendants<FencedCodeBlock>()
|
||||
.Select(block => (block.Line, Content: ToContent(block)));
|
||||
|
||||
//
|
||||
// Tables and code blocks are counted apart. The menu falls back to that number when a
|
||||
// heading cannot tell two files apart, and "Table 2" has to be the second table of the
|
||||
// answer, not the second entry of the menu.
|
||||
//
|
||||
var numberOfTables = 0;
|
||||
var numberOfCodeBlocks = 0;
|
||||
return tables.Concat(codeBlocks)
|
||||
.Where(entry => entry.Content is not null)
|
||||
.OrderBy(entry => entry.Line)
|
||||
.Select((entry, index) => new MessageFile(
|
||||
index + 1,
|
||||
.Select(entry => new MessageFile(
|
||||
entry.Content!.Value.Format.IsTabular() ? ++numberOfTables : ++numberOfCodeBlocks,
|
||||
Caption: HeadingAbove(entry.Line) is { Length: > 0 } heading ? heading : entry.Content!.Value.Fallback,
|
||||
entry.Content!.Value.Format,
|
||||
entry.Content.Value.Text))
|
||||
@ -90,14 +101,22 @@ public static class PlainFileExport
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Turns a fenced code block into a file, when the model marked it as tabular data.
|
||||
/// Turns a fenced code block into a file, when the model marked it as a format we write.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A block the model never closed is left out. That happens when an answer broke off, at the
|
||||
/// output limit of the model for example, and the file would end wherever the answer did: half
|
||||
/// a web page or half a table is nothing anybody wants to save.
|
||||
/// </remarks>
|
||||
private static (string Fallback, FileExportFormat Format, string Text)? ToContent(FencedCodeBlock block)
|
||||
{
|
||||
if (!FileExportFormatExtensions.TryFromCodeFenceLanguage(block.Info, out var format) || format is not (FileExportFormat.CSV or FileExportFormat.TSV))
|
||||
if (block.ClosingFencedCharCount is 0 || !FileExportFormatExtensions.TryFromCodeFenceLanguage(block.Info, out var format))
|
||||
return null;
|
||||
|
||||
var content = block.Lines.ToString();
|
||||
if (!format.IsTabular())
|
||||
return (string.Empty, format, content);
|
||||
|
||||
var blockSeparator = format is FileExportFormat.TSV ? '\t' : ',';
|
||||
var firstLine = content.AsSpan();
|
||||
var lineEnd = firstLine.IndexOf('\n');
|
||||
|
||||
201
app/Tests/Tools/Fixtures/standalone_page.html
Normal file
201
app/Tests/Tools/Fixtures/standalone_page.html
Normal file
@ -0,0 +1,201 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Hello World</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,300..900&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root{
|
||||
--bg:#0a0f10; --bone:#f4eee3; --ember:#e2653a; --mint:#7ac9b0;
|
||||
--mx:50%; --my:50%; --gx:50%; --gy:50%;
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
html,body{height:100%}
|
||||
body{margin:0;overflow:hidden;background:var(--bg);color:var(--bone);
|
||||
font-family:"IBM Plex Sans",system-ui,sans-serif;-webkit-font-smoothing:antialiased}
|
||||
.stage{position:fixed;inset:0;display:grid;place-items:center;isolation:isolate}
|
||||
.stage > *{grid-area:1/1}
|
||||
|
||||
.field{
|
||||
position:absolute;inset:-20%;z-index:0;filter:blur(6px);
|
||||
background:
|
||||
radial-gradient(46% 40% at 24% 28%, rgba(226,101,58,.20), transparent 68%),
|
||||
radial-gradient(52% 46% at 78% 72%, rgba(122,201,176,.16), transparent 70%),
|
||||
radial-gradient(80% 70% at 50% 50%, #121a1b, #070b0c 78%);
|
||||
animation:breathe 18s ease-in-out infinite alternate;
|
||||
}
|
||||
@keyframes breathe{
|
||||
from{transform:scale(1) translate(-1%,1%)}
|
||||
to{transform:scale(1.08) translate(1.5%,-1.5%)}
|
||||
}
|
||||
.grid-base{
|
||||
position:absolute;inset:0;z-index:1;opacity:.07;
|
||||
background-image:
|
||||
linear-gradient(rgba(244,238,227,.9) 1px,transparent 1px),
|
||||
linear-gradient(90deg,rgba(244,238,227,.9) 1px,transparent 1px);
|
||||
background-size:46px 46px;
|
||||
-webkit-mask-image:radial-gradient(70% 70% at 50% 50%,#000,transparent);
|
||||
mask-image:radial-gradient(70% 70% at 50% 50%,#000,transparent);
|
||||
}
|
||||
.grid-scan{
|
||||
position:absolute;inset:0;z-index:2;
|
||||
background-image:
|
||||
linear-gradient(rgba(122,201,176,.55) 1px,transparent 1px),
|
||||
linear-gradient(90deg,rgba(122,201,176,.55) 1px,transparent 1px);
|
||||
background-size:46px 46px;
|
||||
-webkit-mask-image:radial-gradient(230px 230px at var(--mx) var(--my),#000 0%,rgba(0,0,0,.35) 45%,transparent 72%);
|
||||
mask-image:radial-gradient(230px 230px at var(--mx) var(--my),#000 0%,rgba(0,0,0,.35) 45%,transparent 72%);
|
||||
}
|
||||
.glow{position:absolute;inset:0;z-index:2;mix-blend-mode:screen;
|
||||
background:radial-gradient(280px 280px at var(--gx) var(--gy),rgba(226,101,58,.22),transparent 65%)}
|
||||
.sweep{
|
||||
position:absolute;inset:-30% -60%;z-index:2;pointer-events:none;filter:blur(22px);
|
||||
background:linear-gradient(102deg,transparent 44%,rgba(244,238,227,.085) 50%,transparent 56%);
|
||||
animation:sweep 15s linear infinite;
|
||||
}
|
||||
@keyframes sweep{from{transform:translateX(-26%)}to{transform:translateX(26%)}}
|
||||
|
||||
.tilt{position:relative;z-index:5;will-change:transform}
|
||||
.hello{
|
||||
position:relative;display:inline-block;margin:0;text-align:center;
|
||||
font-family:"Fraunces",Georgia,serif;
|
||||
font-variation-settings:"opsz" 120;font-weight:500;
|
||||
font-size:clamp(2.4rem,12vw,10.5rem);line-height:.92;letter-spacing:-.025em;
|
||||
animation:float 11s ease-in-out infinite alternate;
|
||||
}
|
||||
@keyframes float{from{transform:translateY(-.02em)}to{transform:translateY(.025em)}}
|
||||
.ghost{
|
||||
position:absolute;inset:0;pointer-events:none;color:transparent;
|
||||
-webkit-text-stroke:.012em rgba(244,238,227,.20);
|
||||
transform:translate(.045em,.045em);
|
||||
animation:drift 13s ease-in-out infinite alternate;
|
||||
}
|
||||
.ghost.deep{-webkit-text-stroke:.01em rgba(122,201,176,.16);transform:translate(-.05em,-.035em);animation-duration:16s}
|
||||
@keyframes drift{to{transform:translate(.075em,.02em)}}
|
||||
.w{display:inline-block;white-space:nowrap}
|
||||
.m{display:inline-block;overflow:hidden;padding-bottom:.1em;margin-bottom:-.1em;vertical-align:bottom}
|
||||
.l{display:inline-block;transform:translateY(115%)}
|
||||
.g{display:inline-block;transition:color .35s ease,transform .4s cubic-bezier(.2,.85,.2,1),text-shadow .4s ease}
|
||||
.l:hover .g{color:var(--mint);transform:translateY(-.055em);text-shadow:0 0 26px rgba(122,201,176,.45)}
|
||||
.caret{
|
||||
display:inline-block;width:.055em;height:.72em;margin-left:.07em;vertical-align:-.02em;
|
||||
background:var(--ember);box-shadow:0 0 22px rgba(226,101,58,.6);
|
||||
animation:blink 1.15s steps(1,end) infinite;
|
||||
}
|
||||
@keyframes blink{0%,49%{opacity:1}50%,100%{opacity:0}}
|
||||
body.play .l{animation:rise .82s var(--d) cubic-bezier(.16,1,.3,1) both}
|
||||
@keyframes rise{from{transform:translateY(115%)}to{transform:translateY(0)}}
|
||||
body.play .caret{animation:blink 1.15s steps(1,end) .95s infinite,caretin .5s .9s both}
|
||||
@keyframes caretin{from{opacity:0;transform:scaleY(.2)}to{opacity:1;transform:scaleY(1)}}
|
||||
body.play .ghost{animation:ghostin 1.4s .15s ease both,drift 13s 1.6s ease-in-out infinite alternate}
|
||||
@keyframes ghostin{from{opacity:0}to{opacity:1}}
|
||||
|
||||
.frame{position:absolute;inset:clamp(14px,3.2vw,42px);z-index:4;border:1px solid rgba(244,238,227,.09);
|
||||
clip-path:inset(0 0 100% 0);animation:open 1.5s .35s cubic-bezier(.16,1,.3,1) both}
|
||||
@keyframes open{to{clip-path:inset(0 0 0 0)}}
|
||||
.tick{position:absolute;width:14px;height:14px;z-index:4;opacity:0;animation:tickin .6s 1.2s ease both}
|
||||
@keyframes tickin{from{opacity:0;transform:scale(.4)}to{opacity:1;transform:scale(1)}}
|
||||
.tl{top:clamp(14px,3.2vw,42px);left:clamp(14px,3.2vw,42px);border-top:1px solid var(--ember);border-left:1px solid var(--ember)}
|
||||
.tr{top:clamp(14px,3.2vw,42px);right:clamp(14px,3.2vw,42px);border-top:1px solid var(--mint);border-right:1px solid var(--mint)}
|
||||
.bl{bottom:clamp(14px,3.2vw,42px);left:clamp(14px,3.2vw,42px);border-bottom:1px solid var(--mint);border-left:1px solid var(--mint)}
|
||||
.br{bottom:clamp(14px,3.2vw,42px);right:clamp(14px,3.2vw,42px);border-bottom:1px solid var(--ember);border-right:1px solid var(--ember)}
|
||||
.vignette{position:absolute;inset:0;z-index:3;
|
||||
background:radial-gradient(75% 65% at 50% 50%,transparent 40%,rgba(3,6,7,.72) 100%)}
|
||||
.grain{
|
||||
position:absolute;inset:-50%;z-index:6;pointer-events:none;opacity:.055;mix-blend-mode:overlay;
|
||||
background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
|
||||
animation:grain 5s steps(5) infinite;
|
||||
}
|
||||
@keyframes grain{
|
||||
0%{transform:translate(0,0)}20%{transform:translate(-3%,2%)}40%{transform:translate(2%,-3%)}
|
||||
60%{transform:translate(-2%,-2%)}80%{transform:translate(3%,1%)}100%{transform:translate(0,0)}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce){
|
||||
*{animation-duration:.001s!important;animation-iteration-count:1!important;transition-duration:.001s!important}
|
||||
.l{transform:none}.caret{opacity:1;animation:none}.sweep,.grain,.field{animation:none}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="stage">
|
||||
<div class="field" aria-hidden="true"></div>
|
||||
<div class="grid-base" aria-hidden="true"></div>
|
||||
<div class="grid-scan" aria-hidden="true"></div>
|
||||
<div class="glow" aria-hidden="true"></div>
|
||||
<div class="sweep" aria-hidden="true"></div>
|
||||
|
||||
<div class="tilt">
|
||||
<h1 class="hello" id="hello" aria-label="Hello World"></h1>
|
||||
</div>
|
||||
|
||||
<div class="vignette" aria-hidden="true"></div>
|
||||
<div class="frame" aria-hidden="true"></div>
|
||||
<i class="tick tl" aria-hidden="true"></i><i class="tick tr" aria-hidden="true"></i>
|
||||
<i class="tick bl" aria-hidden="true"></i><i class="tick br" aria-hidden="true"></i>
|
||||
<div class="grain" aria-hidden="true"></div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
const TEXT = "Hello World";
|
||||
const hello = document.getElementById("hello");
|
||||
const reduce = matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
const words = TEXT.split(" ");
|
||||
|
||||
hello.innerHTML =
|
||||
`<span class="ghost deep" aria-hidden="true">${TEXT}</span>` +
|
||||
`<span class="ghost" aria-hidden="true">${TEXT}</span>`;
|
||||
|
||||
let i = 0;
|
||||
words.forEach((word, wi) => {
|
||||
const w = document.createElement("span");
|
||||
w.className = "w";
|
||||
w.setAttribute("aria-hidden", "true");
|
||||
[...word].forEach(ch => {
|
||||
const m = document.createElement("span"); m.className = "m";
|
||||
const l = document.createElement("span"); l.className = "l";
|
||||
l.style.setProperty("--d", (0.28 + i * 0.052) + "s");
|
||||
const g = document.createElement("span"); g.className = "g"; g.textContent = ch;
|
||||
m.appendChild(l); l.appendChild(g); w.appendChild(m); i++;
|
||||
});
|
||||
hello.appendChild(w);
|
||||
if (wi < words.length - 1) hello.appendChild(document.createTextNode(" "));
|
||||
});
|
||||
const caret = document.createElement("i");
|
||||
caret.className = "caret"; caret.setAttribute("aria-hidden", "true");
|
||||
hello.appendChild(caret);
|
||||
|
||||
const play = () => {
|
||||
document.body.classList.remove("play");
|
||||
void document.body.offsetWidth;
|
||||
document.body.classList.add("play");
|
||||
};
|
||||
play();
|
||||
addEventListener("pointerdown", play);
|
||||
|
||||
const root = document.documentElement, tilt = document.querySelector(".tilt");
|
||||
let tx = innerWidth / 2, ty = innerHeight / 2, gx = tx, gy = ty, px = 0, py = 0;
|
||||
|
||||
addEventListener("pointermove", e => {
|
||||
tx = e.clientX; ty = e.clientY;
|
||||
root.style.setProperty("--mx", tx + "px");
|
||||
root.style.setProperty("--my", ty + "px");
|
||||
}, {passive:true});
|
||||
addEventListener("pointerleave", () => { tx = innerWidth/2; ty = innerHeight/2; });
|
||||
|
||||
(function raf(){
|
||||
gx += (tx - gx) * 0.07; gy += (ty - gy) * 0.07;
|
||||
px += ((tx / innerWidth - .5) - px) * 0.05;
|
||||
py += ((ty / innerHeight - .5) - py) * 0.05;
|
||||
root.style.setProperty("--gx", gx + "px");
|
||||
root.style.setProperty("--gy", gy + "px");
|
||||
if (!reduce) tilt.style.transform =
|
||||
`perspective(900px) rotateY(${px * 9}deg) rotateX(${-py * 7}deg) translate3d(${px * 16}px,${py * 12}px,0)`;
|
||||
requestAnimationFrame(raf);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
134
app/Tests/Tools/PlainFileExportTests.cs
Normal file
134
app/Tests/Tools/PlainFileExportTests.cs
Normal file
@ -0,0 +1,134 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
using AIStudio.Tools;
|
||||
|
||||
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()
|
||||
{
|
||||
var files = PlainFileExport.ExtractFiles(Lines("```html", PAGE, "```"), ',');
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
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.");
|
||||
});
|
||||
}
|
||||
|
||||
[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.");
|
||||
|
||||
var files = PlainFileExport.ExtractFiles(answer, ',');
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
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));
|
||||
});
|
||||
}
|
||||
|
||||
[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)
|
||||
{
|
||||
var files = PlainFileExport.ExtractFiles(Lines($"```{infoString}", "The content.", "```"), ',');
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(files, Has.Count.EqualTo(1));
|
||||
Assert.That(files[0].Format, Is.EqualTo(expectedFormat));
|
||||
Assert.That(files[0].Content, Is.EqualTo("The content."));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ATildeFenceIsOfferedAsWell()
|
||||
{
|
||||
var files = PlainFileExport.ExtractFiles(Lines("~~~latex", @"\section{Results}", "~~~"), ',');
|
||||
|
||||
Assert.That(files.Select(file => file.Format), Is.EqualTo(new[] { FileExportFormat.LATEX }));
|
||||
}
|
||||
|
||||
[TestCase("```css", TestName = "A language AI Studio writes no file for")]
|
||||
[TestCase("```", TestName = "A fence without a language")]
|
||||
public void AnyOtherCodeBlockIsNotOffered(string openingFence)
|
||||
{
|
||||
var files = PlainFileExport.ExtractFiles(Lines(openingFence, "body { margin: 0; }", "```"), ',');
|
||||
|
||||
Assert.That(files, Is.Empty);
|
||||
}
|
||||
|
||||
[TestCase("```html", "<html><body><p>The answer broke off here", TestName = "Half a web page")]
|
||||
[TestCase("```csv", "Quarter,Revenue", TestName = "Half a table")]
|
||||
public void ACodeBlockTheModelNeverClosedIsNotOffered(string openingFence, string content)
|
||||
{
|
||||
var files = PlainFileExport.ExtractFiles(Lines("The answer starts normally.", string.Empty, openingFence, content), ',');
|
||||
|
||||
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}",
|
||||
"```");
|
||||
|
||||
var files = PlainFileExport.ExtractFiles(answer, ',');
|
||||
|
||||
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),
|
||||
}), "The first code block is code block 1, even though a table stands before it.");
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user