mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-11-03 20:27:21 +00:00
Added PowerPoint templates to the Slide Assistant (#1055)
Co-authored-by: Thorsten Sommer <SommerEngineering@users.noreply.github.com>
This commit is contained in:
1 parent
8c5cfa78b6
commit
6eb23ebbcb
136 files changed
+16535
-522
No files matched your search
@@ -0,0 +1,121 @@
|
||||
using AIStudio.Settings;
|
||||
|
||||
namespace AIStudio.Tests.Settings;
|
||||
|
||||
/// <summary>
|
||||
/// Checks how a file a configuration plugin names in a setting is found, e.g., the PowerPoint
|
||||
/// template it ships for the Slide Assistant.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public sealed class PluginFilePathTests
|
||||
{
|
||||
private string pluginDirectory = string.Empty;
|
||||
|
||||
[SetUp]
|
||||
public void CreatePluginDirectory()
|
||||
{
|
||||
this.pluginDirectory = Path.Combine(Path.GetTempPath(), $"plugin-file-path-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(Path.Combine(this.pluginDirectory, "templates"));
|
||||
File.WriteAllBytes(Path.Combine(this.pluginDirectory, "Corporate.pptx"), [1]);
|
||||
File.WriteAllBytes(Path.Combine(this.pluginDirectory, "templates", "Corporate.potx"), [1]);
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void DeletePluginDirectory() => Directory.Delete(this.pluginDirectory, true);
|
||||
|
||||
[Test]
|
||||
public void AFileOfThePluginIsFoundByItsRelativePath()
|
||||
{
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(PluginFilePath.TryResolve("Corporate.pptx", this.pluginDirectory, out var nextToPlugin, out var issue), Is.True, issue);
|
||||
Assert.That(nextToPlugin, Is.EqualTo(Path.Combine(Path.GetFullPath(this.pluginDirectory), "Corporate.pptx")));
|
||||
Assert.That(PluginFilePath.TryResolve("templates/Corporate.potx", this.pluginDirectory, out var inFolder, out issue), Is.True, issue);
|
||||
Assert.That(inFolder, Is.EqualTo(Path.Combine(Path.GetFullPath(this.pluginDirectory), "templates", "Corporate.potx")), "Both kinds of slashes work on every system.");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AnEmptyOrAbsolutePathStaysAsItIs()
|
||||
{
|
||||
var absolutePath = Path.Combine(Path.GetTempPath(), "somewhere", "Corporate.potx");
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(PluginFilePath.TryResolve(string.Empty, this.pluginDirectory, out var empty, out _), Is.True, "An organization may decide on no file at all.");
|
||||
Assert.That(empty, Is.Empty);
|
||||
Assert.That(PluginFilePath.TryResolve(absolutePath, this.pluginDirectory, out var absolute, out _), Is.True, "A file on a network share is the organization's business.");
|
||||
Assert.That(absolute, Is.EqualTo(absolutePath));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void APathLeavingThePluginDirectoryIsRejected()
|
||||
{
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(PluginFilePath.TryResolve("../Corporate.pptx", this.pluginDirectory, out var resolved, out var issue), Is.False);
|
||||
Assert.That(issue, Does.Contain(".."));
|
||||
Assert.That(resolved, Is.Empty);
|
||||
Assert.That(PluginFilePath.TryResolve("templates/../../Corporate.pptx", this.pluginDirectory, out _, out _), Is.False);
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AMissingFileIsRejected()
|
||||
{
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(PluginFilePath.TryResolve("Missing.pptx", this.pluginDirectory, out var resolved, out var issue), Is.False);
|
||||
Assert.That(issue, Does.Contain("does not exist"));
|
||||
Assert.That(resolved, Is.Empty);
|
||||
Assert.That(PluginFilePath.TryResolve("Corporate.pptx", string.Empty, out _, out _), Is.False, "Without the plugin directory, a relative path means nothing.");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void APathWhichHasToStayInsideThePluginLeadsToNothingElse()
|
||||
{
|
||||
var absolutePath = Path.Combine(Path.GetTempPath(), "somewhere", "Corporate.potx");
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(PluginFilePath.TryResolveInside("templates\\Corporate.potx", this.pluginDirectory, out var inFolder, out var issue), Is.True, issue);
|
||||
Assert.That(inFolder, Is.EqualTo(Path.Combine(Path.GetFullPath(this.pluginDirectory), "templates", "Corporate.potx")));
|
||||
Assert.That(PluginFilePath.TryResolveInside(absolutePath, this.pluginDirectory, out var absolute, out issue), Is.False, "Where only a file of the plugin will do, an absolute path is no way out.");
|
||||
Assert.That(issue, Does.Contain("outside"));
|
||||
Assert.That(absolute, Is.Empty);
|
||||
Assert.That(PluginFilePath.TryResolveInside(string.Empty, this.pluginDirectory, out _, out _), Is.False, "An empty path names no file.");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AFolderOfThePluginIsFoundByItsRelativePath()
|
||||
{
|
||||
var absolutePath = Path.Combine(Path.GetTempPath(), "somewhere", "Pictures");
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(PluginFilePath.TryResolveDirectory("templates", this.pluginDirectory, out var inPlugin, out var issue), Is.True, issue);
|
||||
Assert.That(inPlugin, Is.EqualTo(Path.Combine(Path.GetFullPath(this.pluginDirectory), "templates")));
|
||||
Assert.That(PluginFilePath.TryResolveDirectory(string.Empty, this.pluginDirectory, out var empty, out _), Is.True, "An organization may decide on no folder at all.");
|
||||
Assert.That(empty, Is.Empty);
|
||||
Assert.That(PluginFilePath.TryResolveDirectory(absolutePath, this.pluginDirectory, out var absolute, out _), Is.True, "A folder on a network share is the organization's business.");
|
||||
Assert.That(absolute, Is.EqualTo(absolutePath));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AFolderFollowsTheRulesOfAFile()
|
||||
{
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(PluginFilePath.TryResolveDirectory("templates/../..", this.pluginDirectory, out var resolved, out var issue), Is.False);
|
||||
Assert.That(issue, Does.Contain(".."));
|
||||
Assert.That(resolved, Is.Empty);
|
||||
Assert.That(PluginFilePath.TryResolveDirectory("Corporate.pptx", this.pluginDirectory, out _, out issue), Is.False, "A file is no folder.");
|
||||
Assert.That(issue, Does.Contain("does not exist"));
|
||||
Assert.That(PluginFilePath.TryResolve("templates", this.pluginDirectory, out _, out _), Is.False, "A folder is no file.");
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
using AIStudio.Settings;
|
||||
using AIStudio.Tools.PowerPoint;
|
||||
|
||||
using Lua;
|
||||
using Lua.Standard;
|
||||
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace AIStudio.Tests.Settings;
|
||||
|
||||
/// <summary>
|
||||
/// Checks the layouts for kinds of slides which an organization preselects through a configuration plugin.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The setting maps the members of an enum to texts, a shape which ManagedConfiguration reads with an
|
||||
/// overload of its own. The settings are reached through Program.SERVICE_PROVIDER, as in the app,
|
||||
/// because a managed setting writes its value and its lock there.
|
||||
/// </remarks>
|
||||
[TestFixture]
|
||||
public sealed class PreselectedLayoutsTests
|
||||
{
|
||||
private static readonly Guid PLUGIN_ID = Guid.Parse("5c2e8a71-3f4d-4b6e-9d0a-8e1f7b3c6a52");
|
||||
|
||||
private SettingsManager settingsManager = null!;
|
||||
private ServiceProvider serviceProvider = null!;
|
||||
private IServiceProvider previousServiceProvider = null!;
|
||||
|
||||
[SetUp]
|
||||
public void CreateSettings()
|
||||
{
|
||||
// Creating the settings data registers every managed setting:
|
||||
this.settingsManager = new SettingsManager(NullLogger<SettingsManager>.Instance, null!);
|
||||
|
||||
this.previousServiceProvider = Program.SERVICE_PROVIDER;
|
||||
this.serviceProvider = new ServiceCollection().AddSingleton(this.settingsManager).BuildServiceProvider();
|
||||
Program.SERVICE_PROVIDER = this.serviceProvider;
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void RestoreApplicationState()
|
||||
{
|
||||
// The managed state of a setting outlives this fixture, so we release what the plugin took over:
|
||||
ManagedConfiguration.CleanupLeftOverManagedConfigurations([], new HashSet<Guid>());
|
||||
|
||||
Program.SERVICE_PROVIDER = this.previousServiceProvider;
|
||||
this.serviceProvider.Dispose();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task APluginPreselectsTheLayoutsItNames()
|
||||
{
|
||||
await ProcessAsync("""{ ["CHAPTER"] = "Section Header", ["content"] = "Title and Content" }""");
|
||||
|
||||
Assert.That(this.PreselectedLayouts, Is.EquivalentTo(new Dictionary<LayoutPurpose, string>
|
||||
{
|
||||
[LayoutPurpose.CHAPTER] = "Section Header",
|
||||
[LayoutPurpose.CONTENT] = "Title and Content",
|
||||
}), "Kinds of slides count whatever their case, as everywhere in a configuration, and the names of the layouts stay as written.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks that a kind of slide which does not exist preselects nothing and leaves the rest of the table intact.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// "CHAPTERS" is the typo an administrator would make, and a newer plugin might name a kind of slide
|
||||
/// which this version does not know yet. Neither may take the valid entries down with it.
|
||||
/// </remarks>
|
||||
[Test]
|
||||
public async Task AnUnknownKindOfSlideIsPassedOver()
|
||||
{
|
||||
await ProcessAsync("""{ ["CHAPTERS"] = "Section Header", ["TITLE"] = "Title Slide", [3] = "Two Content" }""");
|
||||
|
||||
Assert.That(this.PreselectedLayouts, Is.EquivalentTo(new Dictionary<LayoutPurpose, string> { [LayoutPurpose.TITLE] = "Title Slide" }), "Only the valid kind of slide may count, and a number is no kind of slide.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ThePreselectionIsLockedWithoutUserOverride()
|
||||
{
|
||||
await ProcessAsync("""{ ["CHAPTER"] = "Section Header" }""");
|
||||
|
||||
var isManaged = ManagedConfiguration.TryGet(x => x.SlideBuilder, x => x.PreselectedLayouts, out var configMeta);
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(isManaged, Is.True);
|
||||
Assert.That(configMeta.IsLocked, Is.True);
|
||||
Assert.That(configMeta.LockedByConfigPluginId, Is.EqualTo(PLUGIN_ID));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task AnEditableDefaultKeepsTheChoicesOfTheUser()
|
||||
{
|
||||
await ProcessAsync("""{ ["CHAPTER"] = "Section Header" }""", allowUserOverride: true);
|
||||
|
||||
var isManaged = ManagedConfiguration.TryGet(x => x.SlideBuilder, x => x.PreselectedLayouts, out var configMeta);
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(isManaged, Is.True);
|
||||
Assert.That(configMeta.IsLocked, Is.False, "With AllowUserOverride, users may change the layouts.");
|
||||
Assert.That(configMeta.EditableDefaultByConfigPluginId, Is.EqualTo(PLUGIN_ID));
|
||||
Assert.That(this.PreselectedLayouts, Is.EquivalentTo(new Dictionary<LayoutPurpose, string> { [LayoutPurpose.CHAPTER] = "Section Header" }), "The first time, the default of the organization applies.");
|
||||
});
|
||||
|
||||
this.PreselectedLayouts[LayoutPurpose.CHAPTER] = "Chapter";
|
||||
await ProcessAsync("""{ ["CHAPTER"] = "Section Header" }""", allowUserOverride: true);
|
||||
|
||||
Assert.That(this.PreselectedLayouts[LayoutPurpose.CHAPTER], Is.EqualTo("Chapter"), "Once the user changed the layouts, the next start of the plugin must not overwrite them.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task RemovingThePluginHandsBackTheLayoutsOfTheUser()
|
||||
{
|
||||
this.PreselectedLayouts[LayoutPurpose.CONTENT] = "Content";
|
||||
await ProcessAsync("""{ ["CHAPTER"] = "Section Header" }""");
|
||||
|
||||
ManagedConfiguration.CleanupLeftOverManagedConfigurations([], new HashSet<Guid>());
|
||||
|
||||
Assert.That(this.PreselectedLayouts, Is.EquivalentTo(new Dictionary<LayoutPurpose, string> { [LayoutPurpose.CONTENT] = "Content" }), "Without its plugin, the layouts the user chose before apply again.");
|
||||
}
|
||||
|
||||
private Dictionary<LayoutPurpose, string> PreselectedLayouts => this.settingsManager.ConfigurationData.SlideBuilder.PreselectedLayouts;
|
||||
|
||||
/// <summary>
|
||||
/// Lets a configuration plugin set the preselected layouts.
|
||||
/// </summary>
|
||||
/// <param name="luaTable">The Lua table of layout names by kind of slide.</param>
|
||||
/// <param name="allowUserOverride">Whether users may change the layouts.</param>
|
||||
private static async Task ProcessAsync(string luaTable, bool allowUserOverride = false)
|
||||
{
|
||||
var state = LuaState.Create();
|
||||
state.OpenBasicLibrary();
|
||||
state.OpenTableLibrary();
|
||||
|
||||
await state.DoStringAsync($$"""
|
||||
SETTINGS = {}
|
||||
SETTINGS["DataSlideBuilder.PreselectedLayouts"] = {{luaTable}}
|
||||
SETTINGS["DataSlideBuilder.PreselectedLayouts.AllowUserOverride"] = {{(allowUserOverride ? "true" : "false")}}
|
||||
""");
|
||||
|
||||
if (!state.Environment["SETTINGS"].TryRead<LuaTable>(out var settingsTable))
|
||||
throw new InvalidOperationException("The settings of this test are not a Lua table.");
|
||||
|
||||
var processed = ManagedConfiguration.TryProcessConfiguration(x => x.SlideBuilder, x => x.PreselectedLayouts, PLUGIN_ID, settingsTable, false);
|
||||
Assert.That(processed, Is.True, "A table of layout names by kind of slide is a valid configuration of this setting.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
using AIStudio.Settings;
|
||||
using AIStudio.Settings.DataModel;
|
||||
|
||||
using Lua;
|
||||
using Lua.Standard;
|
||||
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace AIStudio.Tests.Settings;
|
||||
|
||||
/// <summary>
|
||||
/// Checks what an organization preselects through a configuration plugin for the maximum number of bullet points per slide.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The settings are reached through Program.SERVICE_PROVIDER, as in the app, because a managed setting
|
||||
/// writes its value and its lock there. The plugin passes the range check of DataSlideBuilder, as
|
||||
/// PluginConfiguration does.
|
||||
/// </remarks>
|
||||
[TestFixture]
|
||||
public sealed class PreselectedMaxBulletPointsTests
|
||||
{
|
||||
private static readonly Guid PLUGIN_ID = Guid.Parse("4c81e2a6-0f3b-4d97-a52e-7b19c6d3f084");
|
||||
|
||||
private SettingsManager settingsManager = null!;
|
||||
private ServiceProvider serviceProvider = null!;
|
||||
private IServiceProvider previousServiceProvider = null!;
|
||||
|
||||
[SetUp]
|
||||
public void CreateSettings()
|
||||
{
|
||||
// Creating the settings data registers every managed setting:
|
||||
this.settingsManager = new SettingsManager(NullLogger<SettingsManager>.Instance, null!);
|
||||
|
||||
this.previousServiceProvider = Program.SERVICE_PROVIDER;
|
||||
this.serviceProvider = new ServiceCollection().AddSingleton(this.settingsManager).BuildServiceProvider();
|
||||
Program.SERVICE_PROVIDER = this.serviceProvider;
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void RestoreApplicationState()
|
||||
{
|
||||
// The managed state of a setting outlives this fixture, so we release what the plugin took over:
|
||||
ManagedConfiguration.CleanupLeftOverManagedConfigurations([], new HashSet<Guid>());
|
||||
|
||||
Program.SERVICE_PROVIDER = this.previousServiceProvider;
|
||||
this.serviceProvider.Dispose();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task APluginPreselectsTheMaximum()
|
||||
{
|
||||
var processed = await ProcessAsync("5");
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(processed, Is.True);
|
||||
Assert.That(this.PreselectedMaximum, Is.EqualTo(5));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task NoMaximumIsAValidPreselection()
|
||||
{
|
||||
this.PreselectedMaximum = 3;
|
||||
var processed = await ProcessAsync("0");
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(processed, Is.True, "An organization may decide on no maximum at all.");
|
||||
Assert.That(this.PreselectedMaximum, Is.Zero);
|
||||
});
|
||||
}
|
||||
|
||||
[TestCase("9")]
|
||||
[TestCase("-1")]
|
||||
public async Task AMaximumOutsideTheRangeIsRejected(string configuredMaximum)
|
||||
{
|
||||
this.PreselectedMaximum = 3;
|
||||
var processed = await ProcessAsync(configuredMaximum);
|
||||
|
||||
ManagedConfiguration.TryGet(x => x.SlideBuilder, x => x.PreselectedMaxBulletPointsPerSlide, out var configMeta);
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(processed, Is.False);
|
||||
Assert.That(this.PreselectedMaximum, Is.EqualTo(3), "A rejected value leaves the choice of the user untouched.");
|
||||
Assert.That(configMeta.IsLocked, Is.False, "A rejected value locks nothing.");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ThePreselectionIsLockedWithoutUserOverride()
|
||||
{
|
||||
await ProcessAsync("5");
|
||||
|
||||
var isManaged = ManagedConfiguration.TryGet(x => x.SlideBuilder, x => x.PreselectedMaxBulletPointsPerSlide, out var configMeta);
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(isManaged, Is.True);
|
||||
Assert.That(configMeta.IsLocked, Is.True);
|
||||
Assert.That(configMeta.LockedByConfigPluginId, Is.EqualTo(PLUGIN_ID));
|
||||
});
|
||||
}
|
||||
|
||||
private int PreselectedMaximum
|
||||
{
|
||||
get => this.settingsManager.ConfigurationData.SlideBuilder.PreselectedMaxBulletPointsPerSlide;
|
||||
set => this.settingsManager.ConfigurationData.SlideBuilder.PreselectedMaxBulletPointsPerSlide = value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lets a configuration plugin preselect the maximum number of bullet points per slide.
|
||||
/// </summary>
|
||||
/// <param name="luaValue">The maximum as the plugin writes it in Lua.</param>
|
||||
/// <returns>Whether the plugin's value was taken.</returns>
|
||||
private static async Task<bool> ProcessAsync(string luaValue)
|
||||
{
|
||||
var state = LuaState.Create();
|
||||
state.OpenBasicLibrary();
|
||||
|
||||
await state.DoStringAsync($"""
|
||||
SETTINGS = {"{}"}
|
||||
SETTINGS["DataSlideBuilder.PreselectedMaxBulletPointsPerSlide"] = {luaValue}
|
||||
""");
|
||||
|
||||
if (!state.Environment["SETTINGS"].TryRead<LuaTable>(out var settingsTable))
|
||||
throw new InvalidOperationException("The settings of this test are not a Lua table.");
|
||||
|
||||
return ManagedConfiguration.TryProcessConfiguration(x => x.SlideBuilder, x => x.PreselectedMaxBulletPointsPerSlide, PLUGIN_ID, settingsTable, false, validator: DataSlideBuilder.IsValidMaxBulletPointsPerSlide);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
using AIStudio.Settings;
|
||||
using AIStudio.Tools.PowerPoint;
|
||||
|
||||
using Lua;
|
||||
using Lua.Standard;
|
||||
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace AIStudio.Tests.Settings;
|
||||
|
||||
/// <summary>
|
||||
/// Checks what an organization preselects through a configuration plugin for the slides of its template.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The setting maps the numbers of the slides to the members of an enum, a shape which ManagedConfiguration
|
||||
/// reads with an overload of its own. The settings are reached through Program.SERVICE_PROVIDER, as in the
|
||||
/// app, because a managed setting writes its value and its lock there.
|
||||
/// </remarks>
|
||||
[TestFixture]
|
||||
public sealed class PreselectedTemplateSlideUsesTests
|
||||
{
|
||||
private static readonly Guid PLUGIN_ID = Guid.Parse("9e4b2d17-6a3c-4f58-b1e0-2c7d8a5f3e91");
|
||||
|
||||
private SettingsManager settingsManager = null!;
|
||||
private ServiceProvider serviceProvider = null!;
|
||||
private IServiceProvider previousServiceProvider = null!;
|
||||
|
||||
[SetUp]
|
||||
public void CreateSettings()
|
||||
{
|
||||
// Creating the settings data registers every managed setting:
|
||||
this.settingsManager = new SettingsManager(NullLogger<SettingsManager>.Instance, null!);
|
||||
|
||||
this.previousServiceProvider = Program.SERVICE_PROVIDER;
|
||||
this.serviceProvider = new ServiceCollection().AddSingleton(this.settingsManager).BuildServiceProvider();
|
||||
Program.SERVICE_PROVIDER = this.serviceProvider;
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void RestoreApplicationState()
|
||||
{
|
||||
// The managed state of a setting outlives this fixture, so we release what the plugin took over:
|
||||
ManagedConfiguration.CleanupLeftOverManagedConfigurations([], new HashSet<Guid>());
|
||||
|
||||
Program.SERVICE_PROVIDER = this.previousServiceProvider;
|
||||
this.serviceProvider.Dispose();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task APluginPreselectsTheSlidesItNumbers()
|
||||
{
|
||||
await ProcessAsync("""{ [1] = "KEEP", [4] = "edit", [7] = "OMIT" }""");
|
||||
|
||||
Assert.That(this.PreselectedUses, Is.EquivalentTo(new Dictionary<int, TemplateSlideUse>
|
||||
{
|
||||
[1] = TemplateSlideUse.KEEP,
|
||||
[4] = TemplateSlideUse.EDIT,
|
||||
[7] = TemplateSlideUse.OMIT,
|
||||
}), "Options count whatever their case, as everywhere in a configuration.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks that an entry which names no slide or no option preselects nothing and leaves the rest of the table intact.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Slides count from 1, so 0 and negative numbers name no slide, and a number in quotes is a text in
|
||||
/// Lua. "KEEPS" is the typo an administrator would make. None may take the valid entries down with it.
|
||||
/// </remarks>
|
||||
[Test]
|
||||
public async Task AnEntryWhichNamesNoSlideOrNoOptionIsPassedOver()
|
||||
{
|
||||
await ProcessAsync("""{ [0] = "KEEP", [-2] = "KEEP", ["3"] = "KEEP", [5] = "KEEPS", ["TITLE"] = "EDIT", [2] = "EDIT" }""");
|
||||
|
||||
Assert.That(this.PreselectedUses, Is.EquivalentTo(new Dictionary<int, TemplateSlideUse> { [2] = TemplateSlideUse.EDIT }), "Only the valid entry may count.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ThePreselectionIsLockedWithoutUserOverride()
|
||||
{
|
||||
await ProcessAsync("""{ [1] = "KEEP" }""");
|
||||
|
||||
var isManaged = ManagedConfiguration.TryGet(x => x.SlideBuilder, x => x.PreselectedTemplateSlideUses, out var configMeta);
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(isManaged, Is.True);
|
||||
Assert.That(configMeta.IsLocked, Is.True);
|
||||
Assert.That(configMeta.LockedByConfigPluginId, Is.EqualTo(PLUGIN_ID));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task AnEditableDefaultKeepsTheChoicesOfTheUser()
|
||||
{
|
||||
await ProcessAsync("""{ [1] = "KEEP" }""", allowUserOverride: true);
|
||||
|
||||
var isManaged = ManagedConfiguration.TryGet(x => x.SlideBuilder, x => x.PreselectedTemplateSlideUses, out var configMeta);
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(isManaged, Is.True);
|
||||
Assert.That(configMeta.IsLocked, Is.False, "With AllowUserOverride, users may change the choices.");
|
||||
Assert.That(configMeta.EditableDefaultByConfigPluginId, Is.EqualTo(PLUGIN_ID));
|
||||
Assert.That(this.PreselectedUses, Is.EquivalentTo(new Dictionary<int, TemplateSlideUse> { [1] = TemplateSlideUse.KEEP }), "The first time, the default of the organization applies.");
|
||||
});
|
||||
|
||||
this.PreselectedUses[1] = TemplateSlideUse.EDIT;
|
||||
await ProcessAsync("""{ [1] = "KEEP" }""", allowUserOverride: true);
|
||||
|
||||
Assert.That(this.PreselectedUses[1], Is.EqualTo(TemplateSlideUse.EDIT), "Once the user changed the choices, the next start of the plugin must not overwrite them.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task RemovingThePluginHandsBackTheChoicesOfTheUser()
|
||||
{
|
||||
this.PreselectedUses[3] = TemplateSlideUse.EDIT;
|
||||
await ProcessAsync("""{ [1] = "KEEP" }""");
|
||||
|
||||
ManagedConfiguration.CleanupLeftOverManagedConfigurations([], new HashSet<Guid>());
|
||||
|
||||
Assert.That(this.PreselectedUses, Is.EquivalentTo(new Dictionary<int, TemplateSlideUse> { [3] = TemplateSlideUse.EDIT }), "Without its plugin, the choices the user made before apply again.");
|
||||
}
|
||||
|
||||
private Dictionary<int, TemplateSlideUse> PreselectedUses => this.settingsManager.ConfigurationData.SlideBuilder.PreselectedTemplateSlideUses;
|
||||
|
||||
/// <summary>
|
||||
/// Lets a configuration plugin preselect what the presentation does with the slides of the template.
|
||||
/// </summary>
|
||||
/// <param name="luaTable">The Lua table of options by number of slide.</param>
|
||||
/// <param name="allowUserOverride">Whether users may change the choices.</param>
|
||||
private static async Task ProcessAsync(string luaTable, bool allowUserOverride = false)
|
||||
{
|
||||
var state = LuaState.Create();
|
||||
state.OpenBasicLibrary();
|
||||
state.OpenTableLibrary();
|
||||
|
||||
await state.DoStringAsync($$"""
|
||||
SETTINGS = {}
|
||||
SETTINGS["DataSlideBuilder.PreselectedTemplateSlideUses"] = {{luaTable}}
|
||||
SETTINGS["DataSlideBuilder.PreselectedTemplateSlideUses.AllowUserOverride"] = {{(allowUserOverride ? "true" : "false")}}
|
||||
""");
|
||||
|
||||
if (!state.Environment["SETTINGS"].TryRead<LuaTable>(out var settingsTable))
|
||||
throw new InvalidOperationException("The settings of this test are not a Lua table.");
|
||||
|
||||
var processed = ManagedConfiguration.TryProcessConfiguration(x => x.SlideBuilder, x => x.PreselectedTemplateSlideUses, PLUGIN_ID, settingsTable, false);
|
||||
Assert.That(processed, Is.True, "A table of options by number of slide is a valid configuration of this setting.");
|
||||
}
|
||||
}
|
||||
@@ -36,10 +36,25 @@ public sealed class ManagedSettingsCoverageTests
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Free text accepts any value, and the settings which hold the ID of a provider or a profile
|
||||
/// accept nothing but a GUID, so a GUID suits them all.
|
||||
/// accept nothing but a GUID, so a GUID suits almost all of them. The others are listed in
|
||||
/// SAMPLE_TEXTS_BY_SETTING.
|
||||
/// </remarks>
|
||||
private const string SAMPLE_TEXT = "e2f4bebe-1a3a-4f98-bbb9-e40e0b9daa14";
|
||||
|
||||
/// <summary>
|
||||
/// The values configured for the settings holding a string which accept no GUID, by the name of the setting.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A path relative to the plugin has to name a file or folder the plugin ships, and the plugin of
|
||||
/// this test ships none. An absolute path is taken as the organization wrote it, so it suits these
|
||||
/// settings. Forward slashes keep the path free of escapes in Lua and work on every system.
|
||||
/// </remarks>
|
||||
private static readonly IReadOnlyDictionary<string, string> SAMPLE_TEXTS_BY_SETTING = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["DataSlideBuilder.TemplatePath"] = Path.Combine(Path.GetTempPath(), "Corporate.potx").Replace('\\', '/'),
|
||||
["DataSlideBuilder.PreselectedPictureLibraryPath"] = Path.Combine(Path.GetTempPath(), "Pictures").Replace('\\', '/'),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// The registered settings this test does not expect a configuration plugin to manage, each with
|
||||
/// the reason.
|
||||
@@ -179,7 +194,7 @@ public sealed class ManagedSettingsCoverageTests
|
||||
return "true";
|
||||
|
||||
if (valueType == typeof(string))
|
||||
return $"\"{SAMPLE_TEXT}\"";
|
||||
return $"\"{SAMPLE_TEXTS_BY_SETTING.GetValueOrDefault(setting.SettingName, SAMPLE_TEXT)}\"";
|
||||
|
||||
if (valueType.IsEnum)
|
||||
return $"\"{Enum.GetName(valueType, defaultValue!)}\"";
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
using AIStudio.Tools.PowerPoint;
|
||||
|
||||
namespace AIStudio.Tests.Tools.PowerPoint;
|
||||
|
||||
/// <summary>
|
||||
/// Checks how the layouts the user chose by name are found in a template, and what the model is told about them.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Uses the default catalog unless a test builds its own template: L1 "Title Slide", L2 "Section
|
||||
/// Header", L3 "Title and Content", L4 "Two Content", L5 "Content with Picture", and L6 "Table".
|
||||
/// </remarks>
|
||||
[TestFixture]
|
||||
public sealed class ChosenLayoutsTests
|
||||
{
|
||||
private static readonly PresentationTemplate TEMPLATE = PresentationTemplate.CreateDefault();
|
||||
private static readonly SlotBounds LEFT_TEXT = new(838_200, 1_825_625, 5_150_000, 4_351_338);
|
||||
|
||||
[Test]
|
||||
public void ALayoutIsFoundByItsExactName()
|
||||
{
|
||||
var chosen = ChosenLayouts.Of(TEMPLATE, new Dictionary<LayoutPurpose, string>
|
||||
{
|
||||
[LayoutPurpose.CHAPTER] = "Section Header",
|
||||
[LayoutPurpose.CONTENT] = "title and content",
|
||||
});
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(chosen.For(LayoutPurpose.CHAPTER)?.Id, Is.EqualTo("L2"));
|
||||
Assert.That(chosen.For(LayoutPurpose.CONTENT), Is.Null, "Names are compared exactly, as the template's author wrote them.");
|
||||
Assert.That(chosen.For(LayoutPurpose.TITLE), Is.Null, "A kind of slide without a choice is left to the model.");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ANameTheTemplateDoesNotHaveCountsAsNoChoice()
|
||||
{
|
||||
var chosen = ChosenLayouts.Of(TEMPLATE, new Dictionary<LayoutPurpose, string>
|
||||
{
|
||||
[LayoutPurpose.CHAPTER] = "Kapiteltrenner",
|
||||
[LayoutPurpose.TITLE] = string.Empty,
|
||||
});
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(chosen, Is.SameAs(ChosenLayouts.NONE));
|
||||
Assert.That(chosen.ToPromptRules(), Is.Empty, "Without choices, the system prompt gets no rules at all.");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TheFirstOfTwoLayoutsWithTheSameNameCounts()
|
||||
{
|
||||
var template = TestTemplate.Read(
|
||||
new TestLayout("Content", TestPlaceholder.Title(), TestPlaceholder.Text(1, TestTemplate.MASTER_BODY_BOUNDS)),
|
||||
new TestLayout("Content", TestPlaceholder.Title(), TestPlaceholder.Text(1, LEFT_TEXT)));
|
||||
|
||||
var chosen = ChosenLayouts.Of(template, new Dictionary<LayoutPurpose, string> { [LayoutPurpose.CONTENT] = "Content" });
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(template.Families.Select(family => family.Name), Is.EqualTo(new[] { "Content", "Content" }), "The test needs two families with the same name.");
|
||||
Assert.That(chosen.For(LayoutPurpose.CONTENT)?.Id, Is.EqualTo("L1"));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TheRulesNameTheLayoutsByTheirIdsOnly()
|
||||
{
|
||||
var rules = ChosenLayouts.Of(TEMPLATE, new Dictionary<LayoutPurpose, string>
|
||||
{
|
||||
[LayoutPurpose.TIMELINE] = "Two Content",
|
||||
[LayoutPurpose.CHAPTER] = "Section Header",
|
||||
}).ToPromptRules();
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(rules, Does.Contain("\n - L2 for each slide which starts a chapter;"));
|
||||
Assert.That(rules, Does.Contain("\n - L4 for timelines, e.g., the course of a project."));
|
||||
Assert.That(rules.IndexOf("L2 for", StringComparison.Ordinal), Is.LessThan(rules.IndexOf("L4 for", StringComparison.Ordinal)), "The rules follow the course of a presentation.");
|
||||
Assert.That(rules, Does.Not.Contain("Section Header").And.Not.Contain("Two Content"), "No text of the template file reaches the system prompt.");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AChoiceForAFramingSlideRequiresSuchASlide()
|
||||
{
|
||||
var chosen = ChosenLayouts.Of(TEMPLATE, new Dictionary<LayoutPurpose, string>
|
||||
{
|
||||
[LayoutPurpose.TIMELINE] = "Two Content",
|
||||
[LayoutPurpose.IMPRINT] = "Title and Content",
|
||||
});
|
||||
|
||||
var rules = chosen.ToPromptRules();
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(chosen.AddedSlides, Is.EqualTo(new[] { LayoutPurpose.IMPRINT }), "A timeline comes only where the content has one.");
|
||||
Assert.That(rules, Does.Contain("\n - The presentation has at least one slide of each of these kinds, on its layout:\n - L3: the imprint, e.g., with the authors, the organization, and the date, as the very last slide;"));
|
||||
Assert.That(rules, Does.Contain("leaves a slot empty rather than inventing anything."), "The model must not invent the details of an imprint.");
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using AIStudio.Tools.PowerPoint;
|
||||
|
||||
namespace AIStudio.Tests.Tools.PowerPoint;
|
||||
|
||||
/// <summary>
|
||||
/// Checks the kinds of slides the user may choose layouts for.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public sealed class LayoutPurposeTests
|
||||
{
|
||||
[Test]
|
||||
public void EveryKindOfSlideIsDescribedForTheUserAndTheModel()
|
||||
{
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
foreach (var purpose in Enum.GetValues<LayoutPurpose>())
|
||||
{
|
||||
Assert.That(purpose.Label(), Is.Not.Empty, $"The kind of slide {purpose} has no label.");
|
||||
Assert.That(purpose.Question(), Does.EndWith("?"), $"The kind of slide {purpose} has no question.");
|
||||
Assert.That(purpose.PromptDescription(), Is.Not.Empty.And.Not.EndWith("."), $"The kind of slide {purpose} has no description, or one which ends where the rule for the model goes on.");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks that no two kinds of slides read the same, since the user tells them apart by their labels only.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void EveryKindOfSlideHasALabelOfItsOwn()
|
||||
{
|
||||
var labels = Enum.GetValues<LayoutPurpose>().Select(purpose => purpose.Label()).ToList();
|
||||
Assert.That(labels, Is.Unique);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks that the kinds of slides are grouped by area, in the order of the areas.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The forms list the kinds of each area together, in the order of the enum. A kind added at the
|
||||
/// end of the enum rather than within its area would break up the areas.
|
||||
/// </remarks>
|
||||
[Test]
|
||||
public void TheKindsOfSlidesFollowTheOrderOfTheAreas()
|
||||
{
|
||||
var areas = Enum.GetValues<LayoutPurpose>().Select(purpose => purpose.Area()).ToList();
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(areas, Is.Ordered, "The kinds of an area stand together, and the areas follow their own order.");
|
||||
Assert.That(areas.Distinct(), Is.EquivalentTo(Enum.GetValues<LayoutPurposeArea>()), "Every area has kinds of slides.");
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
using AIStudio.Tools.PowerPoint;
|
||||
|
||||
namespace AIStudio.Tests.Tools.PowerPoint;
|
||||
|
||||
/// <summary>
|
||||
/// Checks which pictures of a library folder the LLM may choose from.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public sealed class PictureLibraryTests
|
||||
{
|
||||
private string folder = string.Empty;
|
||||
|
||||
[SetUp]
|
||||
public void CreateFolder()
|
||||
{
|
||||
this.folder = Path.Combine(Path.GetTempPath(), $"ai-studio-picture-library-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(this.folder);
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void DeleteFolder() => Directory.Delete(this.folder, true);
|
||||
|
||||
[Test]
|
||||
public void OnlyPngAndJpegPicturesCountAndIconsGoByTheirNames()
|
||||
{
|
||||
this.CreateFiles("rocket.png", "Moon.JPG", "mars.jpeg", "star.svg", "notes.txt", ".hidden.png", "icon-star.png", "icons/flag.png", "IconSet/small/planet.png", "photos/crater.png");
|
||||
|
||||
var library = PictureLibrary.Read(this.folder, new Random(1), PictureLibrary.MAX_LIBRARY_PICTURES, null, CancellationToken.None);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(library.Pictures.Select(picture => picture.Name), Is.EquivalentTo(new[] { "rocket.png", "Moon.JPG", "mars.jpeg", "icon-star.png", "icons/flag.png", "IconSet/small/planet.png", "photos/crater.png" }), "Hidden files stay out, as everywhere in AI Studio.");
|
||||
Assert.That(library.Pictures.Select(picture => picture.Name), Is.Ordered.Using<string>(StringComparer.OrdinalIgnoreCase));
|
||||
Assert.That(library.Pictures.Where(picture => picture.IsIcon).Select(picture => picture.Name), Is.EquivalentTo(new[] { "icon-star.png", "icons/flag.png", "IconSet/small/planet.png" }), "A file or any of its folders may say that it is an icon.");
|
||||
Assert.That(library.PictureCount, Is.EqualTo(7));
|
||||
Assert.That(library.IconCount, Is.EqualTo(3));
|
||||
Assert.That(library.SkippedFileCount, Is.EqualTo(2), "The SVG icon and the notes are neither PNG nor JPEG.");
|
||||
Assert.That(library.IsSampled, Is.False);
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AFolderOfIconsMakesAllOfItsPicturesIcons()
|
||||
{
|
||||
//
|
||||
// The library folder counts like the folders below it, so a folder "Icons" holds icons only.
|
||||
// The folders above it say where the library lies, so a library inside "iconography" gains nothing:
|
||||
//
|
||||
var iconFolder = Path.Combine(this.folder, "Icons");
|
||||
var iconSetFolder = Path.Combine(this.folder, "IconSet");
|
||||
var photoFolder = Path.Combine(this.folder, "iconography", "Photos");
|
||||
this.CreateFiles("Icons/rocket.png", "Icons/planets/mars.png", "IconSet/moon.jpg", "iconography/Photos/crater.png");
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(PictureLibrary.Read(iconFolder, new Random(1), PictureLibrary.MAX_LIBRARY_PICTURES, null, CancellationToken.None).Pictures.Select(picture => picture.IsIcon), Is.EqualTo(new[] { true, true }));
|
||||
Assert.That(PictureLibrary.Read(iconSetFolder + Path.DirectorySeparatorChar, new Random(1), PictureLibrary.MAX_LIBRARY_PICTURES, null, CancellationToken.None).IconCount, Is.EqualTo(1), "A trailing separator changes nothing.");
|
||||
Assert.That(PictureLibrary.Read(photoFolder, new Random(1), PictureLibrary.MAX_LIBRARY_PICTURES, null, CancellationToken.None).IconCount, Is.Zero);
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ALargeLibraryIsSampledAtRandom()
|
||||
{
|
||||
this.CreateFiles([..Enumerable.Range(1, 30).Select(number => $"picture-{number:00}.png")]);
|
||||
|
||||
var sample = PictureLibrary.Read(this.folder, new Random(7), 10, null, CancellationToken.None);
|
||||
var again = PictureLibrary.Read(this.folder, new Random(7), 10, null, CancellationToken.None);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(sample.Pictures, Has.Count.EqualTo(10));
|
||||
Assert.That(sample.Pictures.Select(picture => picture.Name), Is.Unique);
|
||||
Assert.That(sample.Pictures.Select(picture => picture.Name), Is.Ordered.Using<string>(StringComparer.OrdinalIgnoreCase), "The sample is sorted after it is drawn.");
|
||||
Assert.That(sample.PictureCount, Is.EqualTo(30), "The count tells about the whole library.");
|
||||
Assert.That(sample.IsSampled, Is.True);
|
||||
Assert.That(again.Pictures.Select(picture => picture.Name), Is.EqualTo(sample.Pictures.Select(picture => picture.Name)), "The same random numbers draw the same sample.");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void EveryPictureHasTheSameChanceToBeInTheSample()
|
||||
{
|
||||
//
|
||||
// The first pictures fill the sample, so a sampling which only kept them would let the
|
||||
// last pictures never in. Drawn a thousand times, every picture shows up about as often:
|
||||
//
|
||||
this.CreateFiles([..Enumerable.Range(1, 20).Select(number => $"picture-{number:00}.png")]);
|
||||
var random = new Random(3);
|
||||
var counts = new Dictionary<string, int>();
|
||||
for (var draw = 0; draw < 1_000; draw++)
|
||||
{
|
||||
foreach (var picture in PictureLibrary.Read(this.folder, random, 5, null, CancellationToken.None).Pictures)
|
||||
counts[picture.Name] = counts.GetValueOrDefault(picture.Name) + 1;
|
||||
}
|
||||
|
||||
// Each picture is in a sample with a chance of 5 in 20, i.e., about 250 times:
|
||||
Assert.That(counts.Values, Has.All.InRange(180, 320));
|
||||
Assert.That(counts, Has.Count.EqualTo(20));
|
||||
}
|
||||
|
||||
private void CreateFiles(params string[] names)
|
||||
{
|
||||
foreach (var name in names)
|
||||
{
|
||||
var path = Path.Combine(this.folder, name.Replace('/', Path.DirectorySeparatorChar));
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
File.WriteAllBytes(path, [1, 2, 3]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
using AIStudio.Tools.PowerPoint;
|
||||
|
||||
using DocumentFormat.OpenXml.Packaging;
|
||||
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace AIStudio.Tests.Tools.PowerPoint;
|
||||
|
||||
/// <summary>
|
||||
/// Checks that a presentation file comes about without anything next to the file the user chose.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Inside the Flatpak, the save dialog grants access to exactly the chosen file. A temporary file next
|
||||
/// to it, or a file moved there, fails, which no test on another system would notice otherwise. The
|
||||
/// test template reads as L1 "Title" and L2 "Content".
|
||||
/// </remarks>
|
||||
[TestFixture]
|
||||
public sealed class PresentationFileTests
|
||||
{
|
||||
private static readonly SlideDeckSpec DECK = new("Mars",
|
||||
[
|
||||
new SlideSpec("L1", false, "Mars", "A plan", [], [], [], null),
|
||||
new SlideSpec("L2", false, "Why Mars", string.Empty, [new SlideTextBlock([new SlideParagraph("Close to Earth", 0)])], [], [], null),
|
||||
]);
|
||||
|
||||
private static readonly PresentationWriteOptions OPTIONS = new(null, string.Empty, false);
|
||||
|
||||
private static readonly byte[] PREVIOUS_CONTENT = [1, 2, 3];
|
||||
|
||||
private string templateFolder = string.Empty;
|
||||
private string targetFolder = string.Empty;
|
||||
|
||||
[SetUp]
|
||||
public void CreateFolders()
|
||||
{
|
||||
var testFolder = Path.Join(Path.GetTempPath(), "mindwork-ai-studio-tests", Guid.NewGuid().ToString("N"));
|
||||
this.templateFolder = Directory.CreateDirectory(Path.Join(testFolder, "templates")).FullName;
|
||||
this.targetFolder = Directory.CreateDirectory(Path.Join(testFolder, "presentations")).FullName;
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void RemoveFolders() => Directory.Delete(Path.GetDirectoryName(this.templateFolder)!, true);
|
||||
|
||||
[Test]
|
||||
public async Task ThePresentationGoesIntoTheChosenFileOnly()
|
||||
{
|
||||
var templatePath = await this.CreateTemplateAsync();
|
||||
var targetPath = Path.Join(this.targetFolder, "Mars.pptx");
|
||||
await File.WriteAllBytesAsync(targetPath, PREVIOUS_CONTENT);
|
||||
var temporaryFiles = TemporaryFiles();
|
||||
|
||||
var result = await PresentationFile.CreateAsync(templatePath, targetPath, DECK, BasisOf(templatePath), OPTIONS, new NoPictures(), NullLogger.Instance, CancellationToken.None);
|
||||
|
||||
using var document = PresentationDocument.Open(targetPath, false);
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(result.IsWritten, Is.True);
|
||||
Assert.That(Directory.GetFileSystemEntries(this.targetFolder), Is.EqualTo(new[] { targetPath }), "Nothing else came about next to the chosen file.");
|
||||
Assert.That(document.PresentationPart!.SlideParts.Count(), Is.EqualTo(2), "The chosen file holds the presentation instead of what it held before.");
|
||||
Assert.That(TemporaryFiles(), Is.EquivalentTo(temporaryFiles), "The copy of the template is gone.");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task AChangedTemplateLeavesTheChosenFileAlone()
|
||||
{
|
||||
var templatePath = await this.CreateTemplateAsync();
|
||||
var targetPath = Path.Join(this.targetFolder, "Mars.pptx");
|
||||
await File.WriteAllBytesAsync(targetPath, PREVIOUS_CONTENT);
|
||||
var temporaryFiles = TemporaryFiles();
|
||||
|
||||
// Without a basis, nothing is known to fit, as after a change of the template:
|
||||
var result = await PresentationFile.CreateAsync(templatePath, targetPath, DECK, new Dictionary<string, string>(), OPTIONS, new NoPictures(), NullLogger.Instance, CancellationToken.None);
|
||||
|
||||
var targetContent = await File.ReadAllBytesAsync(targetPath);
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(result.IsWritten, Is.False);
|
||||
Assert.That(targetContent, Is.EqualTo(PREVIOUS_CONTENT));
|
||||
Assert.That(TemporaryFiles(), Is.EquivalentTo(temporaryFiles), "The copy of the template is gone.");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task TheTemplateItselfMayBeTheChosenFile()
|
||||
{
|
||||
var templatePath = await this.CreateTemplateAsync();
|
||||
var basis = BasisOf(templatePath);
|
||||
|
||||
var result = await PresentationFile.CreateAsync(templatePath, templatePath, DECK, basis, OPTIONS, new NoPictures(), NullLogger.Instance, CancellationToken.None);
|
||||
|
||||
using var document = PresentationDocument.Open(templatePath, false);
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(result.IsWritten, Is.True);
|
||||
Assert.That(document.PresentationPart!.SlideParts.Count(), Is.EqualTo(2), "The template was copied before it was written over.");
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<string> CreateTemplateAsync()
|
||||
{
|
||||
var templatePath = Path.Join(this.templateFolder, "template.pptx");
|
||||
using var template = TestTemplate.Create(
|
||||
new TestLayout("Title", TestPlaceholder.Title(), TestPlaceholder.Subtitle(new SlotBounds(1_524_000, 3_602_038, 9_144_000, 1_655_762))),
|
||||
new TestLayout("Content", TestPlaceholder.Title(), TestPlaceholder.Text(1, TestTemplate.MASTER_BODY_BOUNDS)));
|
||||
|
||||
await File.WriteAllBytesAsync(templatePath, template.ToArray());
|
||||
return templatePath;
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, string> BasisOf(string templatePath) => PresentationTemplate.ReadFile(templatePath).DescribeBasisOf(DECK);
|
||||
|
||||
/// <summary>
|
||||
/// The files in the folder where the presentations come about; others may have left some there.
|
||||
/// </summary>
|
||||
private static string[] TemporaryFiles()
|
||||
{
|
||||
var folder = Path.Join(Path.GetTempPath(), "mindwork-ai-studio-presentations");
|
||||
return Directory.Exists(folder) ? Directory.GetFiles(folder) : [];
|
||||
}
|
||||
|
||||
private sealed class NoPictures : IPictureSource
|
||||
{
|
||||
public Task<SlidePicture?> LoadAsync(int number, CancellationToken token) => Task.FromResult<SlidePicture?>(null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,549 @@
|
||||
using AIStudio.Tools.PowerPoint;
|
||||
|
||||
using P = DocumentFormat.OpenXml.Presentation;
|
||||
|
||||
namespace AIStudio.Tests.Tools.PowerPoint;
|
||||
|
||||
/// <summary>
|
||||
/// Checks how a template is read: which layouts become families, and what the model is told about them.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public sealed class PresentationTemplateTests
|
||||
{
|
||||
private static readonly SlotBounds SUBTITLE = new(838_200, 3_602_038, 10_515_600, 1_655_762);
|
||||
private static readonly SlotBounds FULL_TEXT = TestTemplate.MASTER_BODY_BOUNDS;
|
||||
private static readonly SlotBounds LEFT_TEXT = new(838_200, 1_825_625, 5_150_000, 4_351_338);
|
||||
private static readonly SlotBounds RIGHT_TEXT = new(6_200_000, 1_825_625, 5_150_000, 4_351_338);
|
||||
private static readonly SlotBounds RIGHT_PICTURE = new(6_200_000, 1_825_625, 5_150_000, 3_500_000);
|
||||
private static readonly SlotBounds LABEL_BELOW_RIGHT_PICTURE = new(6_200_000, 5_375_625, 5_150_000, 300_000);
|
||||
private static readonly SlotBounds LABEL_ABOVE_FULL_TEXT = new(838_200, 1_400_000, 3_000_000, 300_000);
|
||||
|
||||
[Test]
|
||||
public void TheColorVariantsOfALayoutBecomeOneFamily()
|
||||
{
|
||||
var template = TestTemplate.Read(
|
||||
new TestLayout("A Title 1", TestPlaceholder.Title(), TestPlaceholder.Subtitle(SUBTITLE)),
|
||||
new TestLayout("B Title 1", TestPlaceholder.Title(), TestPlaceholder.Subtitle(SUBTITLE)),
|
||||
new TestLayout("A Content 1", TestPlaceholder.Title(), TestPlaceholder.Text(1, FULL_TEXT)),
|
||||
new TestLayout("B Content 1", TestPlaceholder.Title(), TestPlaceholder.Text(1, FULL_TEXT)),
|
||||
new TestLayout("C Content 1", TestPlaceholder.Title(), TestPlaceholder.Text(1, FULL_TEXT)));
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(template.Families.Select(family => family.Id), Is.EqualTo(new[] { "L1", "L2" }));
|
||||
Assert.That(template.Families.Select(family => family.Name), Is.EqualTo(new[] { "Title 1", "Content 1" }), "The model reads the name the variants share, without the word which tells them apart.");
|
||||
Assert.That(template.Families[1].Variants.Select(variant => variant.Label), Is.EqualTo(new[] { "A", "B", "C" }));
|
||||
Assert.That(template.VariantLabels, Is.EqualTo(new[] { "A", "B", "C" }));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void LayoutsWhichOnlyLookAlikeAreNoVariants()
|
||||
{
|
||||
// The DLR template carries "Tabelle/Table 1" and "Tabelle/Table 2" in three colors each. All six
|
||||
// offer a title and a table at the same places, yet only the colors are variants:
|
||||
var template = TestTemplate.Read(
|
||||
new TestLayout("A Table 1", TestPlaceholder.Title(), TestPlaceholder.Table(11, FULL_TEXT)),
|
||||
new TestLayout("A Table 2", TestPlaceholder.Title(), TestPlaceholder.Table(11, FULL_TEXT)),
|
||||
new TestLayout("B Table 1", TestPlaceholder.Title(), TestPlaceholder.Table(11, FULL_TEXT)),
|
||||
new TestLayout("B Table 2", TestPlaceholder.Title(), TestPlaceholder.Table(11, FULL_TEXT)),
|
||||
new TestLayout("C Table 1", TestPlaceholder.Title(), TestPlaceholder.Table(11, FULL_TEXT)));
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(template.Families.Select(family => family.Name), Is.EqualTo(new[] { "Table 1", "Table 2" }));
|
||||
Assert.That(template.Families[0].Variants.Select(variant => variant.Label), Is.EqualTo(new[] { "A", "B", "C" }));
|
||||
Assert.That(template.Families[1].Variants.Select(variant => variant.Label), Is.EqualTo(new[] { "A", "B" }));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AVariantNamedAtTheEndIsFoundAsWell()
|
||||
{
|
||||
var template = TestTemplate.Read(
|
||||
new TestLayout("Title blue", TestPlaceholder.Title(), TestPlaceholder.Subtitle(SUBTITLE)),
|
||||
new TestLayout("Title green", TestPlaceholder.Title(), TestPlaceholder.Subtitle(SUBTITLE)),
|
||||
new TestLayout("Content blue", TestPlaceholder.Title(), TestPlaceholder.Text(1, FULL_TEXT)),
|
||||
new TestLayout("Content green", TestPlaceholder.Title(), TestPlaceholder.Text(1, FULL_TEXT)));
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(template.Families.Select(family => family.Name), Is.EqualTo(new[] { "Title", "Content" }));
|
||||
Assert.That(template.VariantLabels, Is.EqualTo(new[] { "blue", "green" }));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void LayoutsWithoutVariantsKeepTheirNames()
|
||||
{
|
||||
var template = TestTemplate.Read(
|
||||
new TestLayout("Title Slide", TestPlaceholder.Title(), TestPlaceholder.Subtitle(SUBTITLE)),
|
||||
new TestLayout("Two Content", TestPlaceholder.Title(), TestPlaceholder.Text(1, LEFT_TEXT), TestPlaceholder.Text(2, RIGHT_TEXT)));
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(template.Families.Select(family => family.Name), Is.EqualTo(new[] { "Title Slide", "Two Content" }));
|
||||
Assert.That(template.Families.SelectMany(family => family.Variants).Select(variant => variant.Label), Is.All.Null);
|
||||
Assert.That(template.VariantLabels, Is.Empty);
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ALayoutWithoutRoomForContentIsLeftOut()
|
||||
{
|
||||
var template = TestTemplate.Read(
|
||||
new TestLayout("Title Only", TestPlaceholder.Title()),
|
||||
new TestLayout("Blank"),
|
||||
new TestLayout("Content", TestPlaceholder.Title(), TestPlaceholder.Text(1, FULL_TEXT)));
|
||||
|
||||
Assert.That(template.Families.Select(family => family.Name), Is.EqualTo(new[] { "Content" }), "A sample page or a blank page offers the model nothing to fill.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ASmallTextPlaceholderIsALabel()
|
||||
{
|
||||
var template = TestTemplate.Read(
|
||||
new TestLayout("Content with heading", TestPlaceholder.Title(), TestPlaceholder.Label(17, LABEL_ABOVE_FULL_TEXT), TestPlaceholder.Text(1, FULL_TEXT)));
|
||||
|
||||
Assert.That(template.Families[0].Slots.Select(slot => slot.Role), Is.EqualTo(new[] { PlaceholderRole.TITLE, PlaceholderRole.LABEL, PlaceholderRole.TEXT }));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void APlaceholderWithoutPlaceTakesThePlaceOfTheMaster()
|
||||
{
|
||||
var template = TestTemplate.Read(
|
||||
new TestLayout("Content", TestPlaceholder.Title(), TestPlaceholder.Text(1, FULL_TEXT)));
|
||||
|
||||
var title = template.Families[0].Slots[0];
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(title.Role, Is.EqualTo(PlaceholderRole.TITLE));
|
||||
Assert.That(title.Bounds, Is.EqualTo(TestTemplate.MASTER_TITLE_BOUNDS));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SlotsAreNumberedInReadingOrder()
|
||||
{
|
||||
// The file lists the right text first, but a reader starts on the left:
|
||||
var template = TestTemplate.Read(
|
||||
new TestLayout("Two Content", TestPlaceholder.Title(), TestPlaceholder.Text(2, RIGHT_TEXT), TestPlaceholder.Text(1, LEFT_TEXT)));
|
||||
|
||||
var texts = template.Families[0].Slots.Where(slot => slot.Role is PlaceholderRole.TEXT).ToList();
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(texts.Select(slot => slot.Ordinal), Is.EqualTo(new[] { 1, 2 }));
|
||||
Assert.That(texts.Select(slot => slot.Bounds), Is.EqualTo(new[] { LEFT_TEXT, RIGHT_TEXT }));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TheCapacityFollowsSizeAndFontSize()
|
||||
{
|
||||
// At 20 pt, a line takes 304,800 EMU and a character about 127,000 EMU. The box is sized for
|
||||
// 10.5 lines and 50.5 characters, plus the default insets, so that no rounding decides:
|
||||
var bounds = new SlotBounds(838_200, 1_825_625, 6_413_500 + 2 * 91_440, 3_200_400 + 2 * 45_720);
|
||||
var template = TestTemplate.Read(
|
||||
new TestLayout("Content", TestPlaceholder.Title(), TestPlaceholder.Text(1, bounds, 2000)));
|
||||
|
||||
Assert.That(template.Families[0].Slots[1].Capacity, Is.EqualTo(new TextCapacity(10, 50)));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TheCatalogDescribesEverySlotInWords()
|
||||
{
|
||||
var template = TestTemplate.Read(
|
||||
new TestLayout("Picture with text",
|
||||
TestPlaceholder.Title(),
|
||||
TestPlaceholder.Text(1, LEFT_TEXT, 2000),
|
||||
TestPlaceholder.Picture(20, RIGHT_PICTURE),
|
||||
TestPlaceholder.Label(16, LABEL_BELOW_RIGHT_PICTURE, 1200)));
|
||||
|
||||
Assert.That(template.ToCatalogPrompt(), Is.EqualTo(
|
||||
"""- L1 "Picture with text": title (up to 1 line of about 36 characters); text 1 (left, middle, up to 13 lines of about 39 characters); picture 1 (right, middle, landscape); label 1 (below picture 1, up to 1 line of about 65 characters)"""));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void APictureSlotOfAtLeastHalfTheSlideIsABackdrop()
|
||||
{
|
||||
var template = TestTemplate.Read(
|
||||
new TestLayout("Backdrop", TestPlaceholder.Picture(11, new SlotBounds(0, 0, TestTemplate.SLIDE_WIDTH, TestTemplate.SLIDE_HEIGHT)), TestPlaceholder.Title()),
|
||||
new TestLayout("Picture with text", TestPlaceholder.Title(), TestPlaceholder.Text(1, LEFT_TEXT, 2000), TestPlaceholder.Picture(20, RIGHT_PICTURE)));
|
||||
|
||||
var catalog = template.ToCatalogPrompt();
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(catalog, Does.Contain("landscape, backdrop)"), "The model learns where an icon must not go.");
|
||||
Assert.That(catalog, Does.Contain("picture 1 (right, middle, landscape)"));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void WithoutATemplateTheCommonLayoutsAreOffered()
|
||||
{
|
||||
var template = PresentationTemplate.CreateDefault();
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(template.Families.Select(family => family.Name), Is.EqualTo(new[] { "Title Slide", "Section Header", "Title and Content", "Two Content", "Content with Picture", "Table" }));
|
||||
Assert.That(template.VariantLabels, Is.Empty);
|
||||
Assert.That(template.ToCatalogPrompt(), Does.Contain("""- L3 "Title and Content": title (up to 1 line of about 36 characters); text 1 (full width, middle, up to 9 lines of about 58 characters)"""));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AFamilyIsFoundByTheIdTheModelWrote()
|
||||
{
|
||||
var template = TestTemplate.Read(
|
||||
new TestLayout("A Content", TestPlaceholder.Title(), TestPlaceholder.Text(1, FULL_TEXT)),
|
||||
new TestLayout("B Content", TestPlaceholder.Title(), TestPlaceholder.Text(1, FULL_TEXT)));
|
||||
|
||||
var family = template.FindFamily(" l1 ");
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(family, Is.Not.Null, "Models write IDs in whatever case they like.");
|
||||
Assert.That(family?.GetLayout("B").Name, Is.EqualTo("B Content"));
|
||||
Assert.That(family?.GetLayout("Z").Name, Is.EqualTo("A Content"), "A variant the family lacks falls back to its first one.");
|
||||
Assert.That(template.FindFamily("L9"), Is.Null);
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TheSlidesOfATemplateAreReadWithWhatTheirSlotsHold()
|
||||
{
|
||||
using var file = TestTemplate.Create(
|
||||
new TestTemplateOptions(Slides:
|
||||
[
|
||||
new TestSlide(0, (TestPlaceholder.Title(), "Consultation hour"), (new TestPlaceholder(P.PlaceholderValues.SubTitle, 1), "Every Monday\n10:00 to 10:45")),
|
||||
new TestSlide(2, (TestPlaceholder.Title(), "News"), (new TestPlaceholder(null, 1), "AI Studio:\nEINSTEIN:"), (new TestPlaceholder(P.PlaceholderValues.Footer, 12), "Team, 2026-10-12")) { IsHidden = true },
|
||||
]),
|
||||
new TestLayout("A Title", TestPlaceholder.Title(), TestPlaceholder.Subtitle(SUBTITLE)),
|
||||
new TestLayout("A Content", TestPlaceholder.Title(), TestPlaceholder.Text(1, FULL_TEXT), TestPlaceholder.Footer(12, LABEL_BELOW_RIGHT_PICTURE)),
|
||||
new TestLayout("B Content", TestPlaceholder.Title(), TestPlaceholder.Text(1, FULL_TEXT), TestPlaceholder.Footer(12, LABEL_BELOW_RIGHT_PICTURE)));
|
||||
|
||||
var template = PresentationTemplate.Read(file);
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(template.Slides.Select(slide => slide.Number), Is.EqualTo(new[] { 1, 2 }));
|
||||
Assert.That(template.Slides.Select(slide => slide.Title), Is.EqualTo(new[] { "Consultation hour", "News" }));
|
||||
Assert.That(template.Slides.Select(slide => slide.FamilyId), Is.EqualTo(new[] { "L1", "L2" }), "A slide on the second color of a layout belongs to the layout's family all the same.");
|
||||
Assert.That(template.Slides[0].Contents.Select(content => (content.Slot.Role, content.Text)), Is.EqualTo(new[] { (PlaceholderRole.TITLE, "Consultation hour"), (PlaceholderRole.SUBTITLE, "Every Monday\n10:00 to 10:45") }));
|
||||
Assert.That(template.Slides[1].Contents.Select(content => (content.Slot.Role, content.Text)), Is.EqualTo(new[] { (PlaceholderRole.TITLE, "News"), (PlaceholderRole.TEXT, "AI Studio:\nEINSTEIN:") }), "The footer is no slot of the slide.");
|
||||
Assert.That(template.Slides.Select(slide => slide.IsHidden), Is.EqualTo(new[] { false, true }));
|
||||
Assert.That(template.FindSlide(2), Is.SameAs(template.Slides[1]));
|
||||
Assert.That(template.FindSlide(0), Is.Null);
|
||||
Assert.That(template.FindSlide(3), Is.Null);
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void APlaceholderOfASlideFindsItsSlotByIndexWhateverTypeTheSlideStates()
|
||||
{
|
||||
using var file = TestTemplate.Create(
|
||||
new TestTemplateOptions(Slides:
|
||||
[
|
||||
new TestSlide(0, (new TestPlaceholder(P.PlaceholderValues.CenteredTitle), "Questions"), (new TestPlaceholder(null, 13), "Image by somebody")),
|
||||
]),
|
||||
new TestLayout("Picture", TestPlaceholder.Title(), TestPlaceholder.Picture(11, RIGHT_PICTURE), TestPlaceholder.Label(13, LABEL_BELOW_RIGHT_PICTURE)));
|
||||
|
||||
var template = PresentationTemplate.Read(file);
|
||||
Assert.That(template.Slides[0].Contents.Select(content => (content.Slot.Role, content.Text)), Is.EqualTo(new[] { (PlaceholderRole.TITLE, "Questions"), (PlaceholderRole.LABEL, "Image by somebody") }));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AFreeTableIsTheContentOfTheTableSlot()
|
||||
{
|
||||
using var file = TestTemplate.Create(
|
||||
new TestTemplateOptions(Slides:
|
||||
[
|
||||
new TestSlide(0, (TestPlaceholder.Title(), "Costs")) { Elements = [TestElement.Table(FULL_TEXT, ["Item", "Cost"], ["Fuel", "10"])] },
|
||||
new TestSlide(0, (TestPlaceholder.Title(), "Costs"), (new TestPlaceholder(P.PlaceholderValues.Table, 11), string.Empty)) { Elements = [TestElement.Table(FULL_TEXT, ["Item", "Cost"], ["Food", "5"])] },
|
||||
]),
|
||||
new TestLayout("Table", TestPlaceholder.Title(), TestPlaceholder.Table(11, FULL_TEXT)));
|
||||
|
||||
var template = PresentationTemplate.Read(file);
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(template.Slides[0].Contents.Select(content => (content.Slot.Role, content.Text)), Is.EqualTo(new[] { (PlaceholderRole.TITLE, "Costs"), (PlaceholderRole.TABLE, "Item | Cost\nFuel | 10") }), "A table pasted onto the slide is the table of the slide.");
|
||||
Assert.That(template.Slides[1].Contents.Select(content => (content.Slot.Role, content.Text)), Is.EqualTo(new[] { (PlaceholderRole.TITLE, "Costs"), (PlaceholderRole.TABLE, "Item | Cost\nFood | 5") }), "An empty table placeholder makes way for the table the slide shows.");
|
||||
Assert.That(template.ToTemplateSlidesPrompt([TemplateSlideUse.EDIT, TemplateSlideUse.KEEP]), Is.EqualTo(
|
||||
"""
|
||||
- Template slide 1, to edit, with the slots of layout L1: title "Costs"; table 1 "Item | Cost / Fuel | 10"
|
||||
- Template slide 2, to keep unchanged: title "Costs"; table 1 "Item | Cost / Food | 5"
|
||||
""".ReplaceLineEndings("\n")), "The model learns that the slide has a table.");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ATablePlaceholderWinsOverAFreeTable()
|
||||
{
|
||||
using var file = TestTemplate.Create(
|
||||
new TestTemplateOptions(Slides:
|
||||
[
|
||||
new TestSlide(0, (TestPlaceholder.Title(), "Costs")) { Elements = [TestElement.Table(FULL_TEXT, ["Free"], ["Table"]), TestElement.TablePlaceholder(11, FULL_TEXT, ["Placeholder"], ["Table"])] },
|
||||
]),
|
||||
new TestLayout("Table", TestPlaceholder.Title(), TestPlaceholder.Table(11, FULL_TEXT)));
|
||||
|
||||
var template = PresentationTemplate.Read(file);
|
||||
Assert.That(template.Slides[0].Contents.Select(content => (content.Slot.Role, content.Text)), Is.EqualTo(new[] { (PlaceholderRole.TITLE, "Costs"), (PlaceholderRole.TABLE, "Placeholder\nTable") }));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ASlotTheSlideResizesTakesItsSizeOnTheSlide()
|
||||
{
|
||||
// The layout slot holds 10 lines of 50 characters, see TheCapacityFollowsSizeAndFontSize:
|
||||
var bounds = new SlotBounds(838_200, 1_825_625, 6_413_500 + 2 * 91_440, 3_200_400 + 2 * 45_720);
|
||||
var half = bounds with { Width = bounds.Width / 2, Height = bounds.Height / 2 };
|
||||
var nudged = bounds with { X = bounds.X + 50_000 };
|
||||
using var file = TestTemplate.Create(
|
||||
new TestTemplateOptions(Slides:
|
||||
[
|
||||
new TestSlide(0, (TestPlaceholder.Title(), "Rocket"), (TestPlaceholder.Text(1, half), "Reusable")),
|
||||
new TestSlide(0, (TestPlaceholder.Title(), "Fuel"), (TestPlaceholder.Text(1, nudged), "Cheap")),
|
||||
new TestSlide(0, (TestPlaceholder.Title(), "Crew"), (TestPlaceholder.Text(1, half), string.Empty)),
|
||||
]),
|
||||
new TestLayout("Content", TestPlaceholder.Title(), TestPlaceholder.Text(1, bounds, 2000)));
|
||||
|
||||
var template = PresentationTemplate.Read(file);
|
||||
var slots = template.Families[0].Slots;
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(template.Slides[0].MovedSlots.Select(slot => (slot.Role, slot.Ordinal, slot.Bounds, slot.Capacity)), Is.EqualTo(new[] { (PlaceholderRole.TEXT, 1, half, (TextCapacity?)new TextCapacity(5, 25)) }), "Half the size holds half the lines of half the characters.");
|
||||
Assert.That(template.Slides[0].OnSlide(slots[1]).Capacity, Is.EqualTo(new TextCapacity(5, 25)));
|
||||
Assert.That(template.Slides[0].OnSlide(slots[0]), Is.SameAs(slots[0]), "The title stays where the layout puts it.");
|
||||
Assert.That(template.Slides[1].MovedSlots, Is.Empty, "A placeholder dragged a little does not count as moved.");
|
||||
Assert.That(template.ToTemplateSlidesPrompt([TemplateSlideUse.EDIT, TemplateSlideUse.EDIT, TemplateSlideUse.EDIT]), Is.EqualTo(
|
||||
"""
|
||||
- Template slide 1, to edit, with the slots of layout L1: title "Rocket"; text 1 (left, middle, up to 5 lines of about 25 characters, on this slide) "Reusable"
|
||||
- Template slide 2, to edit, with the slots of layout L1: title "Fuel"; text 1 "Cheap"
|
||||
- Template slide 3, to edit, with the slots of layout L1: title "Crew"; text 1 (left, middle, up to 5 lines of about 25 characters, on this slide, empty)
|
||||
""".ReplaceLineEndings("\n")));
|
||||
Assert.That(template.ToTemplateSlidesPrompt([TemplateSlideUse.KEEP]), Is.EqualTo("- Template slide 1, to keep unchanged: title \"Rocket\"; text 1 \"Reusable\""), "A slide to keep needs no sizes.");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AFreeTableTellsWhereItsSlotStarts()
|
||||
{
|
||||
var lower = FULL_TEXT with { Y = FULL_TEXT.Y + 1_000_000 };
|
||||
using var file = TestTemplate.Create(
|
||||
new TestTemplateOptions(Slides:
|
||||
[
|
||||
new TestSlide(0, (TestPlaceholder.Title(), "Costs")) { Elements = [TestElement.Table(FULL_TEXT, ["Item", "Cost"], ["Fuel", "10"])] },
|
||||
new TestSlide(0, (TestPlaceholder.Title(), "Costs")) { Elements = [TestElement.Table(lower, ["Item", "Cost"], ["Fuel", "10"])] },
|
||||
]),
|
||||
new TestLayout("Table", TestPlaceholder.Title(), TestPlaceholder.Table(11, FULL_TEXT)));
|
||||
|
||||
var template = PresentationTemplate.Read(file);
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(template.Slides[0].MovedSlots, Is.Empty, "A table grows with its rows, so a short one at the place of its slot does not shrink the slot.");
|
||||
Assert.That(template.Slides[1].MovedSlots.Select(slot => slot.Bounds), Is.EqualTo(new[] { lower with { Height = FULL_TEXT.Bottom - lower.Y } }), "A table placed lower leaves less room, down to the bottom of its slot.");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TheElementsOfASlideAreReadFromBackToFront()
|
||||
{
|
||||
var line = new SlotBounds(838_200, 6_000_000, 5_000_000, 0);
|
||||
using var file = TestTemplate.Create(
|
||||
new TestTemplateOptions(Slides:
|
||||
[
|
||||
new TestSlide(0, (TestPlaceholder.Title(), "Costs"), (new TestPlaceholder(P.PlaceholderValues.Footer, 12), "Team"))
|
||||
{
|
||||
Elements =
|
||||
[
|
||||
TestElement.Table(FULL_TEXT, ["Item"], ["Fuel"]),
|
||||
TestElement.Picture(RIGHT_PICTURE, 1, 2, 3),
|
||||
TestElement.TextBox(LABEL_BELOW_RIGHT_PICTURE, "Image by somebody"),
|
||||
TestElement.TextBox(LABEL_ABOVE_FULL_TEXT, string.Empty),
|
||||
TestElement.Rectangle(RIGHT_TEXT),
|
||||
TestElement.Line(line),
|
||||
TestElement.Group(LEFT_TEXT, TestElement.TextBox(LEFT_TEXT, "A"), TestElement.TextBox(LEFT_TEXT, "B")),
|
||||
TestElement.Table(RIGHT_TEXT, ["Second"], ["Table"]),
|
||||
TestElement.Chart(LEFT_TEXT),
|
||||
],
|
||||
},
|
||||
]),
|
||||
new TestLayout("Table", TestPlaceholder.Title(), TestPlaceholder.Table(11, FULL_TEXT)));
|
||||
|
||||
var elements = PresentationTemplate.Read(file).Slides[0].Elements;
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(elements.Select(element => (element.Number, element.ShapeId, element.Kind, element.Text)), Is.EqualTo(new[]
|
||||
{
|
||||
(1, 5U, TemplateSlideElementKind.PICTURE, string.Empty),
|
||||
(2, 6U, TemplateSlideElementKind.TEXT_BOX, "Image by somebody"),
|
||||
(3, 8U, TemplateSlideElementKind.SHAPE, string.Empty),
|
||||
(4, 9U, TemplateSlideElementKind.LINE, string.Empty),
|
||||
(5, 10U, TemplateSlideElementKind.GROUP, "A\nB"),
|
||||
(6, 11U, TemplateSlideElementKind.TABLE, "Second\nTable"),
|
||||
(7, 12U, TemplateSlideElementKind.CHART, string.Empty),
|
||||
}), "The footer belongs to the presentation, the empty text box shows nothing, and the first table is the content of the table slot.");
|
||||
Assert.That(elements.Select(element => element.Bounds), Is.EqualTo(new SlotBounds?[] { RIGHT_PICTURE, LABEL_BELOW_RIGHT_PICTURE, RIGHT_TEXT, line, LEFT_TEXT, RIGHT_TEXT with { Height = 2 * 370_840 }, LEFT_TEXT }));
|
||||
Assert.That(elements.Select(element => element.Name), Has.Exactly(1).EqualTo("element 3"));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AnEntryToEditListsTheElementsOfTheSlide()
|
||||
{
|
||||
using var file = TestTemplate.Create(
|
||||
new TestTemplateOptions(Slides:
|
||||
[
|
||||
new TestSlide(0, (TestPlaceholder.Title(), "Rocket"), (new TestPlaceholder(null, 1), "Reusable"))
|
||||
{
|
||||
Elements =
|
||||
[
|
||||
TestElement.Picture(RIGHT_PICTURE, 1, 2, 3),
|
||||
TestElement.TextBox(LABEL_BELOW_RIGHT_PICTURE, "Bildquelle hier angeben"),
|
||||
TestElement.TextBox(new SlotBounds(1_000_000, 3_000_000, 3_000_000, 500_000), "Old note"),
|
||||
TestElement.Rectangle(new SlotBounds(0, 0, TestTemplate.SLIDE_WIDTH, TestTemplate.SLIDE_HEIGHT)),
|
||||
],
|
||||
},
|
||||
new TestSlide(0, (TestPlaceholder.Title(), "Chart")) { Elements = [TestElement.Chart(LEFT_TEXT)] },
|
||||
]),
|
||||
new TestLayout("Content", TestPlaceholder.Title(), TestPlaceholder.Text(1, LEFT_TEXT)));
|
||||
|
||||
var template = PresentationTemplate.Read(file);
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(template.ToTemplateSlidesPrompt([TemplateSlideUse.EDIT, TemplateSlideUse.EDIT]), Is.EqualTo(
|
||||
"""
|
||||
- Template slide 1, to edit, with the slots of layout L1: title "Rocket"; text 1 "Reusable"; element 1 (picture, right, middle); element 2 (text box, right, bottom) "Bildquelle hier angeben"; element 3 (text box, left, middle, overlaps text 1) "Old note"; element 4 (shape, covering most of the slide)
|
||||
- Template slide 2, to edit, with the slots of layout L1: title "Chart"; element 1 (chart, left, middle, overlaps text 1)
|
||||
""".ReplaceLineEndings("\n")), "A slot the slide leaves out takes the place of the layout, where filling it would cover the chart.");
|
||||
Assert.That(template.ToTemplateSlidesPrompt([TemplateSlideUse.KEEP]), Is.EqualTo("- Template slide 1, to keep unchanged: title \"Rocket\"; text 1 \"Reusable\""), "A slide to keep has nothing to remove.");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CopiesOfAHeadingAndItsTextBecomeSlotsOfTheSlide()
|
||||
{
|
||||
using var file = TestTemplate.Create(new TestTemplateOptions(Slides: [StackedHeadings.Slide()]), StackedHeadings.Layout());
|
||||
|
||||
var slide = PresentationTemplate.Read(file).Slides[0];
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(slide.SlotCopies.Select(copy => (copy.Slot.Role, copy.Slot.Ordinal, copy.Slot.Bounds, copy.ShapeId)), Is.EqualTo(new[]
|
||||
{
|
||||
(PlaceholderRole.LABEL, 2, StackedHeadings.SECOND_HEADING, 5U),
|
||||
(PlaceholderRole.TEXT, 2, StackedHeadings.SECOND_TEXT, 6U),
|
||||
(PlaceholderRole.LABEL, 3, StackedHeadings.THIRD_HEADING, 7U),
|
||||
(PlaceholderRole.TEXT, 3, StackedHeadings.THIRD_TEXT, 8U),
|
||||
}), "The copies are numbered after the slots of the layout, in reading order.");
|
||||
Assert.That(slide.SlotCopies.Select(copy => copy.Slot.Capacity), Is.EqualTo(new TextCapacity?[] { new TextCapacity(1, 14), new TextCapacity(3, 25), new TextCapacity(1, 18), new TextCapacity(3, 25) }), "A copy holds what its size allows, as a moved slot does.");
|
||||
Assert.That(slide.Slots.Select(slot => $"{slot.Role.CatalogName()} {slot.Ordinal}"), Is.EqualTo(new[] { "title 1", "label 1", "text 1", "label 2", "text 2", "label 3", "text 3" }));
|
||||
Assert.That(slide.Contents.Select(content => (content.Slot.Role, content.Text)), Is.EqualTo(new[]
|
||||
{
|
||||
(PlaceholderRole.TITLE, "Missions"),
|
||||
(PlaceholderRole.LABEL, "Lorem ipsum"),
|
||||
(PlaceholderRole.TEXT, "Ut wisi enim"),
|
||||
(PlaceholderRole.LABEL, "Lorem"),
|
||||
(PlaceholderRole.TEXT, "Duis autem"),
|
||||
(PlaceholderRole.LABEL, "Nam liber"),
|
||||
(PlaceholderRole.TEXT, "Consectetuer"),
|
||||
}));
|
||||
Assert.That(slide.Elements.Select(element => (element.Kind, element.Text)), Is.EqualTo(new[] { (TemplateSlideElementKind.PICTURE, string.Empty), (TemplateSlideElementKind.TEXT_BOX, "Bildquelle hier angeben") }), "The source of the picture has the height of a heading, yet lines up with none.");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void OnlyAShapeOfTheSizeOfASlotInLineWithItIsACopy()
|
||||
{
|
||||
var twin = new SlotBounds(838_200, 4_100_000, 3_400_000, 1_000_000);
|
||||
using var file = TestTemplate.Create(
|
||||
new TestTemplateOptions(Slides:
|
||||
[
|
||||
new TestSlide(0, (TestPlaceholder.Title(), "Missions"), (new TestPlaceholder(P.PlaceholderValues.Body, 17), "Lorem ipsum"), (TestPlaceholder.Text(1, StackedHeadings.FIRST_TEXT), "Ut wisi enim"))
|
||||
{
|
||||
Elements =
|
||||
[
|
||||
TestElement.TextBox(new SlotBounds(838_200, 1_900_000, 3_400_000, 1_000_000), "On text 1"),
|
||||
TestElement.TextBox(new SlotBounds(838_200, 3_000_000, 2_000_000, 1_000_000), "Other width"),
|
||||
TestElement.TextBox(twin, "Twin"),
|
||||
TestElement.TextBox(twin, "Twin"),
|
||||
new TestElement((_, slidePart) => TestElement.TextBox(new SlotBounds(838_200, 5_200_000, 3_400_000, 1_000_000), "Same ID").Create(2, slidePart)),
|
||||
TestElement.TextBox(new SlotBounds(4_400_000, 1_825_625, 3_400_000, 1_000_000), string.Empty),
|
||||
TestElement.TextBox(StackedHeadings.PICTURE_SOURCE, "Bildquelle hier angeben"),
|
||||
],
|
||||
},
|
||||
]),
|
||||
StackedHeadings.Layout());
|
||||
|
||||
var slide = PresentationTemplate.Read(file).Slides[0];
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(slide.SlotCopies.Select(copy => (copy.Slot.Role, copy.Slot.Ordinal, copy.Slot.Bounds)), Is.EqualTo(new[] { (PlaceholderRole.TEXT, 2, twin) }), "Of two copies in one place, the first counts.");
|
||||
Assert.That(slide.Elements.Select(element => element.Text), Is.EqualTo(new[] { "On text 1", "Other width", "Twin", "Bildquelle hier angeben" }), "A box lying on a slot, a text of another width, the second twin and a box in no line with a slot stay elements; one whose ID the title shares and an empty one are neither.");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CopiesOfCopiesFormAGrid()
|
||||
{
|
||||
// The upper right copy lines up with nothing but the lower right one, which lines up with text 1:
|
||||
var text = new SlotBounds(838_200, 3_500_000, 3_400_000, 1_000_000);
|
||||
var lowerRight = text with { X = 4_400_000 };
|
||||
var upperRight = lowerRight with { Y = 2_300_000 };
|
||||
using var file = TestTemplate.Create(
|
||||
new TestTemplateOptions(Slides:
|
||||
[
|
||||
new TestSlide(0, (TestPlaceholder.Title(), "Missions"), (TestPlaceholder.Text(1, text), "Ut wisi enim"))
|
||||
{
|
||||
Elements = [TestElement.TextBox(upperRight, "Above"), TestElement.TextBox(lowerRight, "Beside")],
|
||||
},
|
||||
]),
|
||||
StackedHeadings.Layout());
|
||||
|
||||
var slide = PresentationTemplate.Read(file).Slides[0];
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(slide.SlotCopies.Select(copy => (copy.Slot.Ordinal, copy.Slot.Bounds)), Is.EqualTo(new[] { (2, upperRight), (3, lowerRight) }));
|
||||
Assert.That(slide.Slots.Select(slot => $"{slot.Role.CatalogName()} {slot.Ordinal}"), Is.EqualTo(new[] { "title 1", "label 1", "text 2", "text 1", "text 3" }), "The slots of the layout keep their numbers; a copy goes where a reader comes across it.");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ALabelRightAboveATextIsItsHeadingEvenRightBelowAnother()
|
||||
{
|
||||
var upper = new SlotBounds(838_200, 1_825_625, 5_150_000, 1_500_000);
|
||||
var heading = new SlotBounds(838_200, 3_350_000, 3_000_000, 300_000);
|
||||
var lower = new SlotBounds(838_200, 3_775_625, 5_150_000, 1_500_000);
|
||||
var template = TestTemplate.Read(new TestLayout("Two texts", TestPlaceholder.Title(), TestPlaceholder.Text(1, upper), TestPlaceholder.Label(17, heading), TestPlaceholder.Text(2, lower)));
|
||||
|
||||
Assert.That(template.ToCatalogPrompt(), Does.Contain("label 1 (above text 2,"), "The heading is closer to the text above it, yet introduces the one below it.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AnEntryToEditOffersTheCopiesAsSlots()
|
||||
{
|
||||
using var file = TestTemplate.Create(new TestTemplateOptions(Slides: [StackedHeadings.Slide()]), StackedHeadings.Layout());
|
||||
|
||||
var template = PresentationTemplate.Read(file);
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(template.ToTemplateSlidesPrompt([TemplateSlideUse.EDIT]), Is.EqualTo(
|
||||
"""
|
||||
- Template slide 1, to edit, with the slots of layout L1: title "Missions"; label 1 "Lorem ipsum"; text 1 (left, middle, up to 3 lines of about 25 characters, on this slide) "Ut wisi enim"; label 2 (above text 2, up to 1 line of about 14 characters, on this slide) "Lorem"; text 2 (left, middle, up to 3 lines of about 25 characters, on this slide) "Duis autem"; label 3 (above text 3, up to 1 line of about 18 characters, on this slide) "Nam liber"; text 3 (left, bottom, up to 3 lines of about 25 characters, on this slide) "Consectetuer"; element 1 (picture, right, middle); element 2 (text box, right, bottom) "Bildquelle hier angeben"
|
||||
"""));
|
||||
Assert.That(template.ToTemplateSlidesPrompt([TemplateSlideUse.KEEP]), Is.EqualTo("- Template slide 1, to keep unchanged: title \"Missions\"; label 1 \"Lorem ipsum\"; text 1 \"Ut wisi enim\"; label 2 \"Lorem\"; text 2 \"Duis autem\"; label 3 \"Nam liber\"; text 3 \"Consectetuer\""), "A slide to keep says what its copies hold, so that no new slide repeats it.");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ASlideOnALayoutWithoutRoomForContentBelongsToNoFamily()
|
||||
{
|
||||
using var file = TestTemplate.Create(
|
||||
new TestTemplateOptions(Slides: [new TestSlide(1, (TestPlaceholder.Title(), "Thank you"))]),
|
||||
new TestLayout("Content", TestPlaceholder.Title(), TestPlaceholder.Text(1, FULL_TEXT)),
|
||||
new TestLayout("Title Only", TestPlaceholder.Title()));
|
||||
|
||||
var template = PresentationTemplate.Read(file);
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(template.Slides[0].FamilyId, Is.Null);
|
||||
Assert.That(template.Slides[0].Title, Is.EqualTo("Thank you"), "What the slide holds is read all the same, so the user knows which slide it is.");
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,769 @@
|
||||
using AIStudio.Tools.PowerPoint;
|
||||
|
||||
using DocumentFormat.OpenXml;
|
||||
using DocumentFormat.OpenXml.Packaging;
|
||||
using DocumentFormat.OpenXml.Validation;
|
||||
|
||||
using A = DocumentFormat.OpenXml.Drawing;
|
||||
using P = DocumentFormat.OpenXml.Presentation;
|
||||
using P14 = DocumentFormat.OpenXml.Office2010.PowerPoint;
|
||||
|
||||
namespace AIStudio.Tests.Tools.PowerPoint;
|
||||
|
||||
/// <summary>
|
||||
/// Checks the presentations written from a template: valid files, filled placeholders, variants,
|
||||
/// pictures, tables, sections, and the template slides a deck keeps or edits.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The test template reads as L1 "Title" (A, B), L2 "Content" (A, B), L3 "Picture with text",
|
||||
/// L4 "Backdrop" and L5 "Table".
|
||||
/// </remarks>
|
||||
[TestFixture]
|
||||
public sealed class PresentationWriterTests
|
||||
{
|
||||
private static readonly SlotBounds SUBTITLE = new(1_524_000, 3_602_038, 9_144_000, 1_655_762);
|
||||
private static readonly SlotBounds HEADING = new(838_200, 1_400_000, 3_000_000, 300_000);
|
||||
private static readonly SlotBounds LEFT_TEXT = new(838_200, 1_825_625, 5_150_000, 4_351_338);
|
||||
private static readonly SlotBounds RIGHT_PICTURE = new(6_200_000, 1_825_625, 5_150_000, 3_500_000);
|
||||
private static readonly SlotBounds WHOLE_SLIDE = new(0, 0, TestTemplate.SLIDE_WIDTH, TestTemplate.SLIDE_HEIGHT);
|
||||
private static readonly SlotBounds FOOTER = new(838_200, 6_356_350, 4_114_800, 365_125);
|
||||
private static readonly SlotBounds SLIDE_NUMBER = new(8_610_600, 6_356_350, 2_743_200, 365_125);
|
||||
|
||||
/// <summary>
|
||||
/// Four times as wide as high, which neither slot of the test template is.
|
||||
/// </summary>
|
||||
private static readonly SlidePicture WIDE_PICTURE = new([1, 2, 3], "image/png", 400, 100);
|
||||
|
||||
private static readonly PresentationWriteOptions OPTIONS = new("A", "Mission team", false);
|
||||
|
||||
private static readonly SlideDeckSpec DECK = new("Mars",
|
||||
[
|
||||
new SlideSpec("L1", false, "Mars", "A plan", [], [], [], null),
|
||||
new SlideSpec("L2", true, "Why Mars", string.Empty, [Text(new SlideParagraph("Close to Earth", 0), new SlideParagraph("Two years per trip", 1))], [], [], null),
|
||||
new SlideSpec("L3", false, "The rocket", string.Empty, [Text(new SlideParagraph("Reusable", 0))], [], [1], null),
|
||||
new SlideSpec("L4", true, "Landing", string.Empty, [], [], [2], null),
|
||||
new SlideSpec("L5", false, "Costs", string.Empty, [], [], [], new SlideTableSpec(["Item", "Cost"], [["Fuel", "10"]])),
|
||||
]);
|
||||
|
||||
[Test]
|
||||
public async Task TheFileIsValidAndHoldsTheNewSlidesOnly()
|
||||
{
|
||||
var (file, _) = await WriteAsync(DECK, OPTIONS, new TestTemplateOptions(ExampleSlides: 2), new FakePictureSource(WIDE_PICTURE, WIDE_PICTURE));
|
||||
using var document = PresentationDocument.Open(file, false);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(ValidationErrorsOf(document), Is.Empty);
|
||||
Assert.That(document.PresentationPart!.SlideParts.Count(), Is.EqualTo(5), "The sample slides of the template are gone, parts included.");
|
||||
Assert.That(SlidesOf(document).Select(TitleOf), Is.EqualTo(new[] { "Mars", "Why Mars", "The rocket", "Landing", "Costs" }), "The presentation holds the slides of the deck, nothing more.");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task EveryChapterBecomesASection()
|
||||
{
|
||||
var (file, _) = await WriteAsync(DECK, OPTIONS, new TestTemplateOptions(ExampleSlides: 2), new FakePictureSource(WIDE_PICTURE, WIDE_PICTURE));
|
||||
using var document = PresentationDocument.Open(file, false);
|
||||
|
||||
var sections = document.PresentationPart!.Presentation!.Descendants<P14.Section>().ToList();
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(sections.Select(section => section.Name?.Value), Is.EqualTo(new[] { "Mars", "Why Mars", "Landing" }), "The section of the template's samples is gone, and the title slide forms a section named after the presentation.");
|
||||
Assert.That(sections.Select(section => section.Descendants<P14.SectionSlideIdListEntry>().Count()), Is.EqualTo(new[] { 1, 2, 2 }));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task OnlyPlaceholdersWithContentAreWritten()
|
||||
{
|
||||
var (file, _) = await WriteAsync(DECK, OPTIONS);
|
||||
using var document = PresentationDocument.Open(file, false);
|
||||
|
||||
// The second slide has no heading for its text, so the label placeholder (idx 17) stays away:
|
||||
var slide = SlidesOf(document)[1].Slide!;
|
||||
var placeholders = slide.Descendants<P.PlaceholderShape>().Select(placeholder => (placeholder.Type?.InnerText ?? "obj", placeholder.Index?.Value)).ToList();
|
||||
var textBody = slide.Descendants<P.Shape>().Single(shape => shape.Descendants<P.PlaceholderShape>().Single().Index?.Value == 1).TextBody!;
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(placeholders, Is.EqualTo(new (string, uint?)[] { ("title", null), ("obj", 1), ("ftr", 10), ("sldNum", 11) }));
|
||||
Assert.That(textBody.Elements<A.Paragraph>().Select(paragraph => paragraph.ParagraphProperties?.Level?.Value ?? 0), Is.EqualTo(new[] { 0, 1 }));
|
||||
Assert.That(textBody.InnerText, Is.EqualTo("Close to EarthTwo years per trip"));
|
||||
Assert.That(slide.Descendants<P.Shape>().Any(shape => shape.InnerText == "Mission team"), Is.True, "The footer carries the text the user chose.");
|
||||
Assert.That(slide.Descendants<A.Field>().Single().Type?.Value, Is.EqualTo("slidenum"), "PowerPoint fills in the number itself.");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task AllSlidesUseTheChosenVariant()
|
||||
{
|
||||
var deck = new SlideDeckSpec("Mars",
|
||||
[
|
||||
new SlideSpec("L1", false, "Mars", string.Empty, [], [], [], null),
|
||||
new SlideSpec("L2", true, "One", string.Empty, [], [], [], null),
|
||||
new SlideSpec("L2", false, "One more", string.Empty, [], [], [], null),
|
||||
new SlideSpec("L2", true, "Two", string.Empty, [], [], [], null),
|
||||
]);
|
||||
|
||||
var (chosen, _) = await WriteAsync(deck, OPTIONS with { VariantLabel = "B" });
|
||||
var (unknown, _) = await WriteAsync(deck, OPTIONS with { VariantLabel = "Z" });
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(LayoutNamesOf(chosen), Is.EqualTo(new[] { "B Title", "B Content", "B Content", "B Content" }), "Chapters keep the variant.");
|
||||
Assert.That(LayoutNamesOf(unknown), Is.EqualTo(new[] { "A Title", "A Content", "A Content", "A Content" }), "A variant the template lacks falls back to its first one.");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task APictureFillsABackdropAndFitsIntoASmallSlot()
|
||||
{
|
||||
var (file, _) = await WriteAsync(DECK, OPTIONS, pictures: new FakePictureSource(WIDE_PICTURE, WIDE_PICTURE));
|
||||
using var document = PresentationDocument.Open(file, false);
|
||||
var slides = SlidesOf(document);
|
||||
|
||||
// In the small slot, the whole picture is scaled down to the slot's width and centered:
|
||||
var small = slides[2].Slide!.Descendants<P.Picture>().Single();
|
||||
var transform = small.ShapeProperties!.Transform2D!;
|
||||
|
||||
// The backdrop is filled, so the sides of the wide picture are cut off evenly:
|
||||
var backdrop = slides[3].Slide!.Descendants<P.Picture>().Single();
|
||||
var crop = backdrop.BlipFill!.GetFirstChild<A.SourceRectangle>()!;
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(transform.Extents!.Cx!.Value, Is.EqualTo(RIGHT_PICTURE.Width));
|
||||
Assert.That(transform.Extents.Cy!.Value, Is.EqualTo(RIGHT_PICTURE.Width / 4));
|
||||
Assert.That(transform.Offset!.Y!.Value, Is.EqualTo(RIGHT_PICTURE.Y + (RIGHT_PICTURE.Height - RIGHT_PICTURE.Width / 4) / 2));
|
||||
Assert.That(crop.Left?.Value, Is.EqualTo(27_778), "16:9 out of 4:1 keeps 44.4 % of the width.");
|
||||
Assert.That(crop.Right?.Value, Is.EqualTo(27_778));
|
||||
Assert.That(crop.Top, Is.Null);
|
||||
Assert.That(slides[2].ImageParts.Count(), Is.EqualTo(1));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task AnIconIsNeverCropped()
|
||||
{
|
||||
var (file, _) = await WriteAsync(DECK, OPTIONS, pictures: new FakePictureSource(WIDE_PICTURE, WIDE_PICTURE with { IsIcon = true }));
|
||||
using var document = PresentationDocument.Open(file, false);
|
||||
|
||||
// The icon lands in the backdrop, which the validator advises against, and keeps its edges anyway:
|
||||
var icon = SlidesOf(document)[3].Slide!.Descendants<P.Picture>().Single();
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(icon.BlipFill!.GetFirstChild<A.SourceRectangle>(), Is.Null);
|
||||
Assert.That(icon.ShapeProperties!.Transform2D!.Extents!.Cx!.Value, Is.EqualTo(WHOLE_SLIDE.Width));
|
||||
Assert.That(icon.ShapeProperties.Transform2D.Extents.Cy!.Value, Is.EqualTo(WHOLE_SLIDE.Width / 4));
|
||||
Assert.That(ValidationErrorsOf(document), Is.Empty);
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task APictureInAFormatPowerPointDoesNotShowIsSkipped()
|
||||
{
|
||||
var (file, result) = await WriteAsync(DECK, OPTIONS, pictures: new FakePictureSource(WIDE_PICTURE with { ContentType = "image/webp" }, WIDE_PICTURE));
|
||||
using var document = PresentationDocument.Open(file, false);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(result.SkippedPictures, Is.EqualTo(new[] { 1 }));
|
||||
Assert.That(SlidesOf(document)[2].Slide!.Descendants<P.Picture>(), Is.Empty, "The slot stays empty rather than holding a picture nobody can see.");
|
||||
Assert.That(ValidationErrorsOf(document), Is.Empty);
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task AnEmptyPictureSlotStaysOnlyWhenTheUserAsksForIt()
|
||||
{
|
||||
var deck = new SlideDeckSpec("Mars",
|
||||
[
|
||||
new SlideSpec("L3", false, "The rocket", string.Empty, [Text(new SlideParagraph("Reusable", 0))], [], [], null),
|
||||
]);
|
||||
|
||||
var (left, _) = await WriteAsync(deck, OPTIONS);
|
||||
var (kept, _) = await WriteAsync(deck, OPTIONS with { KeepEmptyPicturePlaceholders = true });
|
||||
using var leftDocument = PresentationDocument.Open(left, false);
|
||||
using var keptDocument = PresentationDocument.Open(kept, false);
|
||||
var keptSlide = SlidesOf(keptDocument)[0];
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(PicturePlaceholdersOf(SlidesOf(leftDocument)[0]), Is.Empty, "By default, no prompt text stays behind.");
|
||||
Assert.That(PicturePlaceholdersOf(keptSlide), Has.Count.EqualTo(1));
|
||||
Assert.That(PlaceholderShapeOf(keptSlide, "pic", 20).TextBody, Is.Null, "Without a text of its own, the placeholder shows the prompt text of the layout.");
|
||||
Assert.That(keptSlide.ImageParts, Is.Empty);
|
||||
Assert.That(ValidationErrorsOf(keptDocument), Is.Empty);
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ASkippedPictureLeavesItsPlaceholderWhenTheUserAsksForIt()
|
||||
{
|
||||
var (file, result) = await WriteAsync(DECK, OPTIONS with { KeepEmptyPicturePlaceholders = true }, pictures: new FakePictureSource(WIDE_PICTURE with { ContentType = "image/webp" }, WIDE_PICTURE));
|
||||
using var document = PresentationDocument.Open(file, false);
|
||||
var slide = SlidesOf(document)[2];
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(result.SkippedPictures, Is.EqualTo(new[] { 1 }));
|
||||
Assert.That(slide.Slide!.Descendants<P.Picture>(), Is.Empty);
|
||||
Assert.That(PicturePlaceholdersOf(slide), Has.Count.EqualTo(1), "The user can insert the picture there themselves.");
|
||||
Assert.That(ValidationErrorsOf(document), Is.Empty);
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task TheTableUsesTheStyleOfTheTemplate()
|
||||
{
|
||||
var (file, _) = await WriteAsync(DECK, OPTIONS);
|
||||
using var document = PresentationDocument.Open(file, false);
|
||||
|
||||
var table = SlidesOf(document)[4].Slide!.Descendants<A.Table>().Single();
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(table.TableProperties!.GetFirstChild<A.TableStyleId>()?.Text, Is.EqualTo(TestTemplate.DEFAULT_TABLE_STYLE_ID));
|
||||
Assert.That(table.TableProperties.FirstRow?.Value, Is.True, "The headers are the first row.");
|
||||
Assert.That(table.Elements<A.TableRow>().Select(row => row.InnerText), Is.EqualTo(new[] { "ItemCost", "Fuel10" }));
|
||||
Assert.That(table.TableGrid!.Elements<A.GridColumn>().Sum(column => column.Width!.Value), Is.EqualTo(TestTemplate.MASTER_BODY_BOUNDS.Width), "The columns span the slot exactly.");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ATemplateFileBecomesAPresentation()
|
||||
{
|
||||
var (file, _) = await WriteAsync(DECK, OPTIONS, new TestTemplateOptions(AsTemplate: true));
|
||||
using var document = PresentationDocument.Open(file, false);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(document.DocumentType, Is.EqualTo(PresentationDocumentType.Presentation));
|
||||
Assert.That(ValidationErrorsOf(document), Is.Empty);
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task KeptTemplateSlidesStayInTheOrderOfTheDeck()
|
||||
{
|
||||
var deck = new SlideDeckSpec("Weekly",
|
||||
[
|
||||
Keep(1),
|
||||
new SlideSpec("L2", false, "Agenda", string.Empty, [Text(new SlideParagraph("News", 0))], [], [], null),
|
||||
Keep(2),
|
||||
Keep(3),
|
||||
Keep(4),
|
||||
]);
|
||||
|
||||
var (file, _) = await WriteAsync(deck, OPTIONS, TEMPLATE_WITH_SLIDES);
|
||||
using var document = PresentationDocument.Open(file, false);
|
||||
var slides = SlidesOf(document);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(ValidationErrorsOf(document), Is.Empty);
|
||||
Assert.That(slides.Select(TitleOf), Is.EqualTo(new[] { "Consultation hour", "Agenda", "News", "Questions", "Impressum" }));
|
||||
Assert.That(document.PresentationPart!.SlideParts.Count(), Is.EqualTo(5), "The slide the deck leaves out is gone, part included.");
|
||||
Assert.That(BodyParagraphsOf(slides[2]).Select(paragraph => paragraph.InnerText), Is.EqualTo(new[] { "AI Studio:", "EINSTEIN:" }));
|
||||
Assert.That(slides[3].Slide!.Show, Is.Null, "The user chose the slide, so it shows although the template hides it.");
|
||||
Assert.That(document.PresentationPart.Presentation!.SlideIdList!.Elements<P.SlideId>().Select(slideId => slideId.Id!.Value), Is.Unique);
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task AKeptSlideStaysWhereTheDeckPutsItWhateverItsTitle()
|
||||
{
|
||||
var deck = new SlideDeckSpec("Weekly",
|
||||
[
|
||||
Keep(1),
|
||||
Keep(4),
|
||||
new SlideSpec("L2", true, "Agenda", string.Empty, [Text(new SlideParagraph("News", 0))], [], [], null),
|
||||
Keep(2),
|
||||
]);
|
||||
|
||||
var (file, _) = await WriteAsync(deck, OPTIONS, TEMPLATE_WITH_SLIDES);
|
||||
using var document = PresentationDocument.Open(file, false);
|
||||
var sections = document.PresentationPart!.Presentation!.Descendants<P14.Section>().ToList();
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(ValidationErrorsOf(document), Is.Empty);
|
||||
Assert.That(SlidesOf(document).Select(TitleOf), Is.EqualTo(new[] { "Consultation hour", "Impressum", "Agenda", "News" }), "An imprint is a slide like any other: nothing moves it to the end.");
|
||||
Assert.That(sections.Select(section => section.Name?.Value), Is.EqualTo(new[] { "Weekly", "Agenda" }));
|
||||
Assert.That(sections.Select(section => section.Descendants<P14.SectionSlideIdListEntry>().Count()), Is.EqualTo(new[] { 2, 2 }));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task KeptSlidesTakeTheColorVariantAndTheFooterOfThePresentation()
|
||||
{
|
||||
var deck = new SlideDeckSpec("Weekly", [Keep(1), Keep(2)]);
|
||||
|
||||
var (withFooter, _) = await WriteAsync(deck, OPTIONS with { VariantLabel = "B" }, TEMPLATE_WITH_SLIDES);
|
||||
var (withoutFooter, _) = await WriteAsync(deck, OPTIONS with { FooterText = string.Empty }, TEMPLATE_WITH_SLIDES);
|
||||
using var document = PresentationDocument.Open(withFooter, false);
|
||||
var slides = SlidesOf(document);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(ValidationErrorsOf(document), Is.Empty);
|
||||
Assert.That(slides.Select(slide => slide.SlideLayoutPart?.SlideLayout?.CommonSlideData?.Name?.Value), Is.EqualTo(new[] { "B Title", "B Content" }));
|
||||
Assert.That(document.PresentationPart!.SlideMasterParts.Single().SlideLayoutParts.Count(), Is.EqualTo(Layouts().Length), "The layout a slide leaves stays in the template.");
|
||||
Assert.That(FootersOf(slides[0]), Is.EqualTo(new[] { "Mission team" }));
|
||||
Assert.That(FootersOf(slides[1]), Is.EqualTo(new[] { "Mission team" }));
|
||||
Assert.That(FootersOfSlide(withoutFooter, 1), Is.EqualTo(new[] { "Old footer" }), "Without a footer of the presentation, a kept slide keeps its own.");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task AKeptSlideIsLinkedToTheIndicesOfItsNewVariant()
|
||||
{
|
||||
using var templateFile = TestTemplate.Create(
|
||||
new TestTemplateOptions(Slides: [new TestSlide(0, (TestPlaceholder.Title(), "News"), (new TestPlaceholder(null, 1), "A point"), (new TestPlaceholder(P.PlaceholderValues.Footer, 10), "Old footer"))]),
|
||||
new TestLayout("A Content", TestPlaceholder.Title(), TestPlaceholder.Text(1, TestTemplate.MASTER_BODY_BOUNDS), TestPlaceholder.Footer(10, FOOTER)),
|
||||
new TestLayout("B Content", TestPlaceholder.Title(), TestPlaceholder.Text(2, TestTemplate.MASTER_BODY_BOUNDS), TestPlaceholder.Footer(12, FOOTER)));
|
||||
|
||||
var template = PresentationTemplate.Read(templateFile);
|
||||
templateFile.Position = 0;
|
||||
|
||||
using var output = new MemoryStream();
|
||||
await templateFile.CopyToAsync(output);
|
||||
output.Position = 0;
|
||||
await PresentationWriter.WriteAsync(output, template, new SlideDeckSpec("Weekly", [Keep(1)]), OPTIONS with { VariantLabel = "B" }, new FakePictureSource(), CancellationToken.None);
|
||||
output.Position = 0;
|
||||
|
||||
using var document = PresentationDocument.Open(output, false);
|
||||
var slide = SlidesOf(document)[0];
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(ValidationErrorsOf(document), Is.Empty);
|
||||
Assert.That(slide.SlideLayoutPart?.SlideLayout?.CommonSlideData?.Name?.Value, Is.EqualTo("B Content"));
|
||||
Assert.That(slide.Slide!.Descendants<P.PlaceholderShape>().Select(placeholder => (placeholder.Type?.InnerText ?? "obj", placeholder.Index?.Value)), Is.EqualTo(new (string, uint?)[] { ("title", null), ("obj", 2), ("ftr", 12) }));
|
||||
Assert.That(FootersOf(slide), Is.EqualTo(new[] { "Mission team" }));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task AnEditedSlideTakesTheNewTextsInTheFormattingOfTheOldOnes()
|
||||
{
|
||||
using var templateFile = TestTemplate.Create(TEMPLATE_WITH_SLIDES, Layouts());
|
||||
using (var templateDocument = PresentationDocument.Open(templateFile, true))
|
||||
{
|
||||
//
|
||||
// The author made the title of the news slide bold by hand, and left a sub-point
|
||||
// waiting for text: without a bullet, and in a size of its own:
|
||||
//
|
||||
var news = SlidesOf(templateDocument)[1];
|
||||
foreach (var run in PlaceholderShapeOf(news, "title", null).Descendants<A.Run>().ToList())
|
||||
run.RunProperties = new A.RunProperties { Bold = true };
|
||||
|
||||
PlaceholderShapeOf(news, "obj", 1).TextBody!.Append(new A.Paragraph(new A.ParagraphProperties(new A.NoBullet()) { Level = 1 }, new A.EndParagraphRunProperties { FontSize = 1600 }));
|
||||
}
|
||||
|
||||
var deck = new SlideDeckSpec("Weekly", [Edit(2, "News of the week", texts: [Text(new SlideParagraph("A new release", 0), new SlideParagraph("Faster", 1))], labels: ["This week"])]);
|
||||
var (file, _) = await WriteFromAsync(templateFile, deck, OPTIONS);
|
||||
using var document = PresentationDocument.Open(file, false);
|
||||
var slide = SlidesOf(document)[0];
|
||||
var paragraphs = BodyParagraphsOf(slide);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(ValidationErrorsOf(document), Is.Empty);
|
||||
Assert.That(TitleOf(slide), Is.EqualTo("News of the week"));
|
||||
Assert.That(PlaceholderShapeOf(slide, "title", null).Descendants<A.Run>().Select(run => run.RunProperties?.Bold?.Value), Is.All.True, "The new title keeps the formatting of the old one.");
|
||||
Assert.That(paragraphs.Select(paragraph => paragraph.InnerText), Is.EqualTo(new[] { "A new release", "Faster" }));
|
||||
Assert.That(paragraphs[1].ParagraphProperties?.Level?.Value, Is.EqualTo(1));
|
||||
Assert.That(paragraphs[1].ParagraphProperties?.GetFirstChild<A.NoBullet>(), Is.Not.Null, "A sub-point keeps what the author chose for sub-points.");
|
||||
Assert.That(paragraphs[1].Elements<A.Run>().Single().RunProperties?.FontSize?.Value, Is.EqualTo(1600));
|
||||
Assert.That(paragraphs[0].Elements<A.Run>().Single().RunProperties?.FontSize, Is.Null, "A main point keeps what the author chose for main points.");
|
||||
Assert.That(PlaceholderShapeOf(slide, "body", 17).TextBody!.InnerText, Is.EqualTo("This week"), "A slot the template slide left out gets its placeholder from the layout.");
|
||||
Assert.That(slide.Slide!.Descendants<P.NonVisualDrawingProperties>().Select(properties => properties.Id!.Value), Is.Unique);
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhatAnEditedSlideLeavesEmptyKeepsTheContentOfTheTemplate()
|
||||
{
|
||||
var deck = new SlideDeckSpec("Weekly", [Edit(2, labels: ["This week"])]);
|
||||
|
||||
var (file, _) = await WriteAsync(deck, OPTIONS, TEMPLATE_WITH_SLIDES);
|
||||
using var document = PresentationDocument.Open(file, false);
|
||||
var slide = SlidesOf(document)[0];
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(TitleOf(slide), Is.EqualTo("News"));
|
||||
Assert.That(BodyParagraphsOf(slide).Select(paragraph => paragraph.InnerText), Is.EqualTo(new[] { "AI Studio:", "EINSTEIN:" }));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task APictureOfAnEditedSlideReplacesTheOneOfTheTemplate()
|
||||
{
|
||||
// Last week's presentation is this week's template:
|
||||
var (lastWeek, _) = await WriteAsync(DECK, OPTIONS, pictures: new FakePictureSource(WIDE_PICTURE, WIDE_PICTURE));
|
||||
var tallPicture = new SlidePicture([4, 5, 6], "image/png", 100, 400);
|
||||
var deck = new SlideDeckSpec("Mars", [Edit(3, pictures: [1])]);
|
||||
|
||||
var (file, _) = await WriteFromAsync(lastWeek, deck, OPTIONS, new FakePictureSource(tallPicture));
|
||||
using var document = PresentationDocument.Open(file, false);
|
||||
var slide = SlidesOf(document)[0];
|
||||
using var pictureData = new MemoryStream();
|
||||
using (var stream = slide.ImageParts.Single().GetStream(FileMode.Open, FileAccess.Read))
|
||||
stream.CopyTo(pictureData);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(ValidationErrorsOf(document), Is.Empty);
|
||||
Assert.That(TitleOf(slide), Is.EqualTo("The rocket"));
|
||||
Assert.That(BodyParagraphsOf(slide).Select(paragraph => paragraph.InnerText), Is.EqualTo(new[] { "Reusable" }));
|
||||
Assert.That(slide.Slide!.Descendants<P.Picture>().Count(), Is.EqualTo(1));
|
||||
Assert.That(pictureData.ToArray(), Is.EqualTo(new byte[] { 4, 5, 6 }), "The old picture leaves the file along with its shape.");
|
||||
Assert.That(slide.Slide!.Descendants<P.Picture>().Single().ShapeProperties!.Transform2D!.Extents!.Cy!.Value, Is.EqualTo(RIGHT_PICTURE.Height), "The old picture was fit into its slot, which therefore stays as large as the layout makes it.");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task APictureGoesWhereTheSlideMovedItsSlot()
|
||||
{
|
||||
var moved = new SlotBounds(6_200_000, 4_000_000, 2_000_000, 1_500_000);
|
||||
var templateOptions = new TestTemplateOptions(Slides: [new TestSlide(4, (TestPlaceholder.Title(), "The rocket"), (TestPlaceholder.Picture(20, moved), string.Empty))]);
|
||||
var deck = new SlideDeckSpec("Mars", [Edit(1, pictures: [1])]);
|
||||
|
||||
var (file, _) = await WriteAsync(deck, OPTIONS, templateOptions, new FakePictureSource(WIDE_PICTURE));
|
||||
using var document = PresentationDocument.Open(file, false);
|
||||
var transform = SlidesOf(document)[0].Slide!.Descendants<P.Picture>().Single().ShapeProperties!.Transform2D!;
|
||||
var bounds = new SlotBounds(transform.Offset!.X!.Value, transform.Offset.Y!.Value, transform.Extents!.Cx!.Value, transform.Extents.Cy!.Value);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(ValidationErrorsOf(document), Is.Empty);
|
||||
Assert.That(moved.Contains(bounds, 0, 0), Is.True, $"The picture fits into the slot where the slide put it, but lies at {bounds}.");
|
||||
Assert.That(bounds.Width, Is.EqualTo(moved.Width), "The wide picture takes the whole width of the moved slot.");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ATableOfAnEditedSlideReplacesTheFreeTableOfTheTemplate()
|
||||
{
|
||||
var deck = new SlideDeckSpec("Mars",
|
||||
[
|
||||
Edit(1, table: new SlideTableSpec(["Item", "Cost"], [["Fuel", "10"], ["Food", "5"]])),
|
||||
Edit(2, table: new SlideTableSpec(["Step", "Date"], [["Launch", "2030"]])),
|
||||
]);
|
||||
|
||||
var (file, _) = await WriteAsync(deck, OPTIONS, TEMPLATE_WITH_FREE_TABLES);
|
||||
using var document = PresentationDocument.Open(file, false);
|
||||
var slides = SlidesOf(document);
|
||||
var shapeTree = slides[0].Slide!.CommonSlideData!.ShapeTree!;
|
||||
var table = shapeTree.Elements<P.GraphicFrame>().Single();
|
||||
var note = shapeTree.Elements<P.Shape>().Single(shape => shape.TextBody?.InnerText == "Source: last year");
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(ValidationErrorsOf(document), Is.Empty);
|
||||
Assert.That(RowsOf(table), Is.EqualTo(new[] { "Item | Cost", "Fuel | 10", "Food | 5" }), "No row of the old table is left below the shorter new one.");
|
||||
Assert.That(table.Descendants<P.PlaceholderShape>().Select(placeholder => (placeholder.Type?.InnerText, placeholder.Index?.Value)), Is.EqualTo(new (string?, uint?)[] { ("tbl", 11) }));
|
||||
Assert.That(shapeTree.Elements().ToList().IndexOf(table), Is.LessThan(shapeTree.Elements().ToList().IndexOf(note)), "The new table lies where the old one did, behind what lay in front of it.");
|
||||
Assert.That(RowsOf(slides[1].Slide!.Descendants<P.GraphicFrame>().Single()), Is.EqualTo(new[] { "Step | Date", "Launch | 2030" }));
|
||||
Assert.That(slides[1].Slide!.Descendants<P.PlaceholderShape>().Count(placeholder => placeholder.Type?.InnerText == "tbl"), Is.EqualTo(1), "The empty placeholder the free table stood in for is gone.");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task WhatAnEditedSlideLeavesEmptyKeepsTheFreeTable()
|
||||
{
|
||||
var deck = new SlideDeckSpec("Mars", [Edit(1, "New costs")]);
|
||||
|
||||
var (file, _) = await WriteAsync(deck, OPTIONS, TEMPLATE_WITH_FREE_TABLES);
|
||||
using var document = PresentationDocument.Open(file, false);
|
||||
var slide = SlidesOf(document)[0];
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(TitleOf(slide), Is.EqualTo("New costs"));
|
||||
Assert.That(RowsOf(slide.Slide!.Descendants<P.GraphicFrame>().Single()), Has.Count.EqualTo(6));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task AnEditedSlideLosesWhatTheModelRemoves()
|
||||
{
|
||||
var deck = new SlideDeckSpec("Weekly", [Edit(1, texts: [Text(new SlideParagraph("A new release", 0))]) with { Removals = ["element 1", "element 2", "label 1"] }]);
|
||||
|
||||
var (file, _) = await WriteAsync(deck, OPTIONS, TEMPLATE_WITH_ELEMENTS);
|
||||
using var document = PresentationDocument.Open(file, false);
|
||||
var slide = SlidesOf(document)[0];
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(ValidationErrorsOf(document), Is.Empty);
|
||||
Assert.That(BodyParagraphsOf(slide).Select(paragraph => paragraph.InnerText), Is.EqualTo(new[] { "A new release" }));
|
||||
Assert.That(slide.Slide!.Descendants<P.Picture>(), Is.Empty);
|
||||
Assert.That(slide.ImageParts, Is.Empty, "The picture leaves the file along with its shape.");
|
||||
Assert.That(slide.Slide.Descendants<P.Shape>().Select(shape => shape.TextBody?.InnerText), Has.None.EqualTo("Bildquelle hier angeben"));
|
||||
Assert.That(slide.Slide.Descendants<P.PlaceholderShape>().Where(placeholder => placeholder.Index?.Value == 17), Is.Empty, "The label of the template is gone, with its sample text.");
|
||||
Assert.That(slide.Slide.Descendants<A.SolidFill>().Count(), Is.EqualTo(1), "What the model does not remove stays.");
|
||||
Assert.That(slide.ChartParts.Count(), Is.EqualTo(1));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task AnEditedSlideFillsTheCopiesOfItsSlotsInTheirFormatting()
|
||||
{
|
||||
using var templateFile = TestTemplate.Create(new TestTemplateOptions(Slides: [StackedHeadings.Slide()]), StackedHeadings.Layout());
|
||||
var deck = new SlideDeckSpec("Missions", [Edit(1, texts: [Text(new SlideParagraph("To Mars", 0)), Text(new SlideParagraph("To the Moon", 0))], labels: ["Mars", "Moon"]) with { Removals = ["label 3", "text 3"] }]);
|
||||
|
||||
var (file, _) = await WriteFromAsync(templateFile, deck, OPTIONS);
|
||||
using var document = PresentationDocument.Open(file, false);
|
||||
var slide = SlidesOf(document)[0];
|
||||
var secondHeading = ShapeWithIdOf(slide, 5);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(ValidationErrorsOf(document), Is.Empty);
|
||||
Assert.That(PlaceholderShapeOf(slide, "body", 17).TextBody!.InnerText, Is.EqualTo("Mars"));
|
||||
Assert.That(BodyParagraphsOf(slide).Select(paragraph => paragraph.InnerText), Is.EqualTo(new[] { "To Mars" }));
|
||||
Assert.That(secondHeading.TextBody!.InnerText, Is.EqualTo("Moon"), "The second label is the first copy of the heading.");
|
||||
Assert.That(secondHeading.ShapeProperties!.GetFirstChild<A.SolidFill>()?.RgbColorModelHex?.Val?.Value, Is.EqualTo(StackedHeadings.HEADING_FILL), "A copy keeps the look of the heading it repeats.");
|
||||
Assert.That(ShapeWithIdOf(slide, 6).TextBody!.InnerText, Is.EqualTo("To the Moon"));
|
||||
Assert.That(slide.Slide!.Descendants<P.NonVisualDrawingProperties>().Select(properties => properties.Id!.Value), Has.None.EqualTo(7U).And.None.EqualTo(8U), "The third heading and its text are removed.");
|
||||
Assert.That(slide.Slide.Descendants<P.Shape>().Select(shape => shape.TextBody?.InnerText), Has.Some.EqualTo("Bildquelle hier angeben"), "What the model does not remove stays.");
|
||||
Assert.That(slide.Slide.Descendants<P.Picture>().Count(), Is.EqualTo(1));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task TheCopiesOfSlotsStayFillableOnAnotherVariant()
|
||||
{
|
||||
using var templateFile = TestTemplate.Create(new TestTemplateOptions(Slides: [StackedHeadings.Slide()]), StackedHeadings.Layout("A Content"), StackedHeadings.Layout("B Content"));
|
||||
var deck = new SlideDeckSpec("Missions", [Edit(1, labels: ["Mars", "Moon", "Venus"])]);
|
||||
|
||||
var (file, _) = await WriteFromAsync(templateFile, deck, OPTIONS with { VariantLabel = "B" });
|
||||
using var document = PresentationDocument.Open(file, false);
|
||||
var slide = SlidesOf(document)[0];
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(ValidationErrorsOf(document), Is.Empty);
|
||||
Assert.That(slide.SlideLayoutPart?.SlideLayout?.CommonSlideData?.Name?.Value, Is.EqualTo("B Content"));
|
||||
Assert.That(PlaceholderShapeOf(slide, "body", 17).TextBody!.InnerText, Is.EqualTo("Mars"));
|
||||
Assert.That(ShapeWithIdOf(slide, 5).TextBody!.InnerText, Is.EqualTo("Moon"));
|
||||
Assert.That(ShapeWithIdOf(slide, 7).TextBody!.InnerText, Is.EqualTo("Venus"));
|
||||
Assert.That(ShapeWithIdOf(slide, 6).TextBody!.InnerText, Is.EqualTo("Duis autem"), "A copy the slide leaves empty keeps the content of the template.");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task RemovingAChartRemovesItsPart()
|
||||
{
|
||||
var deck = new SlideDeckSpec("Weekly", [Edit(1) with { Removals = ["element 4"] }]);
|
||||
|
||||
var (file, _) = await WriteAsync(deck, OPTIONS, TEMPLATE_WITH_ELEMENTS);
|
||||
using var document = PresentationDocument.Open(file, false);
|
||||
var slide = SlidesOf(document)[0];
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(ValidationErrorsOf(document), Is.Empty);
|
||||
Assert.That(slide.Slide!.Descendants<P.GraphicFrame>(), Is.Empty);
|
||||
Assert.That(slide.ChartParts, Is.Empty);
|
||||
Assert.That(slide.ImageParts.Count(), Is.EqualTo(1), "The picture nobody removed keeps its part.");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task RemovingAnAnimatedShapeDropsTheAnimationsOfItsSlide()
|
||||
{
|
||||
using var templateFile = TestTemplate.Create(TEMPLATE_WITH_ELEMENTS with { Slides = [TEMPLATE_WITH_ELEMENTS.Slides![0], TEMPLATE_WITH_ELEMENTS.Slides[0]] }, Layouts());
|
||||
using (var templateDocument = PresentationDocument.Open(templateFile, true))
|
||||
{
|
||||
// The text box naming the source of the picture fades in on both slides:
|
||||
foreach (var templateSlide in SlidesOf(templateDocument))
|
||||
templateSlide.Slide!.Timing = new P.Timing(new P.BuildList(new P.BuildParagraph { ShapeId = "6", GroupId = 0U }));
|
||||
}
|
||||
|
||||
var deck = new SlideDeckSpec("Weekly", [Edit(1) with { Removals = ["element 2"] }, Edit(2) with { Removals = ["element 3"] }]);
|
||||
var (file, _) = await WriteFromAsync(templateFile, deck, OPTIONS);
|
||||
using var document = PresentationDocument.Open(file, false);
|
||||
var slides = SlidesOf(document);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(ValidationErrorsOf(document), Is.Empty);
|
||||
Assert.That(slides[0].Slide!.Timing, Is.Null, "An animation of a shape which is gone would break the slide.");
|
||||
Assert.That(slides[1].Slide!.Timing, Is.Not.Null, "The animated shape is still there.");
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Slides of the Layouts: a title slide, three content slides of which one is hidden
|
||||
/// and one an imprint, and a hint nobody keeps.
|
||||
/// </summary>
|
||||
private static readonly TestTemplateOptions TEMPLATE_WITH_SLIDES = new(Slides:
|
||||
[
|
||||
new TestSlide(0, (TestPlaceholder.Title(), "Consultation hour"), (new TestPlaceholder(P.PlaceholderValues.SubTitle, 1), "Every Monday"), (new TestPlaceholder(P.PlaceholderValues.Footer, 10), "Old footer")),
|
||||
new TestSlide(2, (TestPlaceholder.Title(), "News"), (new TestPlaceholder(null, 1), "AI Studio:\nEINSTEIN:"), (new TestPlaceholder(P.PlaceholderValues.Footer, 10), "Old footer")),
|
||||
new TestSlide(2, (TestPlaceholder.Title(), "Questions")) { IsHidden = true },
|
||||
new TestSlide(2, (TestPlaceholder.Title(), "Impressum"), (new TestPlaceholder(null, 1), "Thema:\tConsultation hour\nDatum:\t2026-10-12\nAutoren:\tThe team")),
|
||||
new TestSlide(0, (TestPlaceholder.Title(), "A hint to delete")),
|
||||
]);
|
||||
|
||||
/// <summary>
|
||||
/// Slides of the Layouts on the table layout whose tables are no placeholders: one
|
||||
/// pasted onto the slide, with a note in front of it, and one standing in for an empty placeholder.
|
||||
/// </summary>
|
||||
private static readonly TestTemplateOptions TEMPLATE_WITH_FREE_TABLES = new(Slides:
|
||||
[
|
||||
new TestSlide(6, (TestPlaceholder.Title(), "Costs"))
|
||||
{
|
||||
Elements =
|
||||
[
|
||||
TestElement.Table(TestTemplate.MASTER_BODY_BOUNDS, ["Item", "Cost"], ["A", "1"], ["B", "2"], ["C", "3"], ["D", "4"], ["E", "5"]),
|
||||
TestElement.TextBox(new SlotBounds(838_200, 6_000_000, 4_000_000, 300_000), "Source: last year"),
|
||||
],
|
||||
},
|
||||
new TestSlide(6, (TestPlaceholder.Title(), "Plan"), (new TestPlaceholder(P.PlaceholderValues.Table, 11), string.Empty))
|
||||
{
|
||||
Elements = [TestElement.Table(TestTemplate.MASTER_BODY_BOUNDS, ["Step", "Date"], ["Test", "2029"])],
|
||||
},
|
||||
]);
|
||||
|
||||
/// <summary>
|
||||
/// A slide of the Layouts on the content layout with a sample label, and with
|
||||
/// elements of its own: a picture (element 1), a text box naming its source (element 2), a
|
||||
/// colored rectangle (element 3), and a chart (element 4).
|
||||
/// </summary>
|
||||
private static readonly TestTemplateOptions TEMPLATE_WITH_ELEMENTS = new(Slides:
|
||||
[
|
||||
new TestSlide(2, (TestPlaceholder.Title(), "News"), (new TestPlaceholder(P.PlaceholderValues.Body, 17), "Lorem ipsum"), (new TestPlaceholder(null, 1), "AI Studio:\nEINSTEIN:"))
|
||||
{
|
||||
Elements =
|
||||
[
|
||||
TestElement.Picture(RIGHT_PICTURE, 7, 8, 9),
|
||||
TestElement.TextBox(new SlotBounds(6_200_000, 5_400_000, 5_150_000, 300_000), "Bildquelle hier angeben"),
|
||||
TestElement.Rectangle(new SlotBounds(0, 6_000_000, TestTemplate.SLIDE_WIDTH, 300_000)),
|
||||
TestElement.Chart(LEFT_TEXT),
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
private static SlideSpec Keep(int templateSlide, bool startsChapter = false) => new(string.Empty, startsChapter, string.Empty, string.Empty, [], [], [], null, templateSlide);
|
||||
|
||||
private static SlideSpec Edit(int templateSlide, string title = "", IReadOnlyList<SlideTextBlock>? texts = null, IReadOnlyList<string>? labels = null, IReadOnlyList<int>? pictures = null, SlideTableSpec? table = null) => new(string.Empty, false, title, string.Empty, texts ?? [], labels ?? [], pictures ?? [], table, templateSlide);
|
||||
|
||||
/// <summary>
|
||||
/// The rows of a table, with " | " between their cells.
|
||||
/// </summary>
|
||||
private static List<string> RowsOf(P.GraphicFrame table) => table.Descendants<A.TableRow>()
|
||||
.Select(row => string.Join(" | ", row.Elements<A.TableCell>().Select(cell => cell.InnerText)))
|
||||
.ToList();
|
||||
|
||||
/// <summary>
|
||||
/// The shape of a slide which fills the placeholder of the given type and index.
|
||||
/// </summary>
|
||||
private static P.Shape PlaceholderShapeOf(SlidePart slide, string type, uint? index) => slide.Slide!.Descendants<P.Shape>()
|
||||
.Single(shape => shape.Descendants<P.PlaceholderShape>().Any(placeholder => (placeholder.Type?.InnerText ?? "obj") == type && placeholder.Index?.Value == index));
|
||||
|
||||
/// <summary>
|
||||
/// The empty placeholders of a slide which wait for a picture.
|
||||
/// </summary>
|
||||
private static List<P.Shape> PicturePlaceholdersOf(SlidePart slide) => slide.Slide!.Descendants<P.Shape>()
|
||||
.Where(shape => shape.Descendants<P.PlaceholderShape>().Any(placeholder => placeholder.Type?.InnerText == "pic"))
|
||||
.ToList();
|
||||
|
||||
/// <summary>
|
||||
/// The shape of a slide with the given ID, e.g., a text box the template slide carries.
|
||||
/// </summary>
|
||||
private static P.Shape ShapeWithIdOf(SlidePart slide, uint id) => slide.Slide!.Descendants<P.Shape>()
|
||||
.Single(shape => shape.NonVisualShapeProperties?.NonVisualDrawingProperties?.Id?.Value == id);
|
||||
|
||||
private static TestLayout[] Layouts() =>
|
||||
[
|
||||
new("A Title", TestPlaceholder.Title(), TestPlaceholder.Subtitle(SUBTITLE), TestPlaceholder.Footer(10, FOOTER), TestPlaceholder.SlideNumber(11, SLIDE_NUMBER)),
|
||||
new("B Title", TestPlaceholder.Title(), TestPlaceholder.Subtitle(SUBTITLE), TestPlaceholder.Footer(10, FOOTER), TestPlaceholder.SlideNumber(11, SLIDE_NUMBER)),
|
||||
new("A Content", TestPlaceholder.Title(), TestPlaceholder.Label(17, HEADING), TestPlaceholder.Text(1, TestTemplate.MASTER_BODY_BOUNDS), TestPlaceholder.Footer(10, FOOTER), TestPlaceholder.SlideNumber(11, SLIDE_NUMBER)),
|
||||
new("B Content", TestPlaceholder.Title(), TestPlaceholder.Label(17, HEADING), TestPlaceholder.Text(1, TestTemplate.MASTER_BODY_BOUNDS), TestPlaceholder.Footer(10, FOOTER), TestPlaceholder.SlideNumber(11, SLIDE_NUMBER)),
|
||||
new("Picture with text", TestPlaceholder.Title(), TestPlaceholder.Text(1, LEFT_TEXT), TestPlaceholder.Picture(20, RIGHT_PICTURE)),
|
||||
new("Backdrop", TestPlaceholder.Picture(11, WHOLE_SLIDE), TestPlaceholder.Title()),
|
||||
new("Table", TestPlaceholder.Title(), TestPlaceholder.Table(11, TestTemplate.MASTER_BODY_BOUNDS)),
|
||||
];
|
||||
|
||||
private static async Task<WrittenPresentation> WriteAsync(SlideDeckSpec deck, PresentationWriteOptions options, TestTemplateOptions? templateOptions = null, IPictureSource? pictures = null)
|
||||
{
|
||||
using var templateFile = TestTemplate.Create(templateOptions ?? new TestTemplateOptions(), Layouts());
|
||||
return await WriteFromAsync(templateFile, deck, options, pictures);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a presentation from a template file of the test's own, e.g., from a presentation written before.
|
||||
/// </summary>
|
||||
private static async Task<WrittenPresentation> WriteFromAsync(Stream templateFile, SlideDeckSpec deck, PresentationWriteOptions options, IPictureSource? pictures = null)
|
||||
{
|
||||
templateFile.Position = 0;
|
||||
var template = PresentationTemplate.Read(templateFile);
|
||||
templateFile.Position = 0;
|
||||
|
||||
// The writer edits a copy of the template, as the Slide Assistant does with one on disk:
|
||||
var output = new MemoryStream();
|
||||
await templateFile.CopyToAsync(output);
|
||||
output.Position = 0;
|
||||
var result = await PresentationWriter.WriteAsync(output, template, deck, options, pictures ?? new FakePictureSource(), CancellationToken.None);
|
||||
output.Position = 0;
|
||||
return new WrittenPresentation(output, result);
|
||||
}
|
||||
|
||||
private static List<string> ValidationErrorsOf(PresentationDocument document) => new OpenXmlValidator(FileFormatVersions.Office2019)
|
||||
.Validate(document)
|
||||
.Select(error => $"{error.Part?.Uri} {error.Path?.XPath}: {error.Description}")
|
||||
.ToList();
|
||||
|
||||
/// <summary>
|
||||
/// The paragraphs of the text placeholder with index 1.
|
||||
/// </summary>
|
||||
private static List<A.Paragraph> BodyParagraphsOf(SlidePart slide) => slide.Slide!.Descendants<P.Shape>()
|
||||
.Single(shape => shape.Descendants<P.PlaceholderShape>().Any(placeholder => placeholder.Index?.Value == 1))
|
||||
.TextBody!.Elements<A.Paragraph>()
|
||||
.ToList();
|
||||
|
||||
private static List<string> FootersOfSlide(MemoryStream file, Index slide)
|
||||
{
|
||||
using var document = PresentationDocument.Open(file, false);
|
||||
return FootersOf(SlidesOf(document)[slide]);
|
||||
}
|
||||
|
||||
private static List<string> FootersOf(SlidePart slide) => slide.Slide!.Descendants<P.Shape>()
|
||||
.Where(shape => shape.Descendants<P.PlaceholderShape>().Any(placeholder => placeholder.Type?.InnerText == "ftr"))
|
||||
.Select(shape => shape.TextBody!.InnerText)
|
||||
.ToList();
|
||||
|
||||
private static List<SlidePart> SlidesOf(PresentationDocument document)
|
||||
{
|
||||
var presentationPart = document.PresentationPart!;
|
||||
return presentationPart.Presentation!.SlideIdList!.Elements<P.SlideId>()
|
||||
.Select(slideId => (SlidePart)presentationPart.GetPartById(slideId.RelationshipId!.Value!))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static string TitleOf(SlidePart slide) => slide.Slide!.Descendants<P.Shape>()
|
||||
.Single(shape => shape.Descendants<P.PlaceholderShape>().Any(placeholder => placeholder.Type?.InnerText == "title"))
|
||||
.TextBody!.InnerText;
|
||||
|
||||
private static List<string?> LayoutNamesOf(MemoryStream file)
|
||||
{
|
||||
using var document = PresentationDocument.Open(file, false);
|
||||
return SlidesOf(document).Select(slide => slide.SlideLayoutPart?.SlideLayout?.CommonSlideData?.Name?.Value).ToList();
|
||||
}
|
||||
|
||||
private static SlideTextBlock Text(params SlideParagraph[] paragraphs) => new(paragraphs);
|
||||
|
||||
private sealed class FakePictureSource(params SlidePicture?[] pictures) : IPictureSource
|
||||
{
|
||||
public Task<SlidePicture?> LoadAsync(int number, CancellationToken token) => Task.FromResult(number >= 1 && number <= pictures.Length ? pictures[number - 1] : null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A presentation a test wrote, and what the writer reported beyond writing it.
|
||||
/// </summary>
|
||||
/// <param name="File">The presentation, positioned at its start.</param>
|
||||
/// <param name="Result">What happened beyond writing.</param>
|
||||
private sealed record WrittenPresentation(MemoryStream File, PresentationWriteResult Result);
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
using AIStudio.Tools.PowerPoint;
|
||||
|
||||
using P = DocumentFormat.OpenXml.Presentation;
|
||||
|
||||
namespace AIStudio.Tests.Tools.PowerPoint;
|
||||
|
||||
/// <summary>
|
||||
/// Checks how the slide deck is read out of a model answer, and how it is shown as an outline.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public sealed class SlideDeckSpecTests
|
||||
{
|
||||
[Test]
|
||||
public void TheJsonIsReadOutOfProseAndACodeFence()
|
||||
{
|
||||
const string ANSWER = """
|
||||
Here is your presentation:
|
||||
```json
|
||||
{ "title": "Mars", "slides": [ { "layout": "L3", "title": "Why Mars", "texts": [["Close to Earth", { "text": "Two years per trip", "level": 1 }]] } ] }
|
||||
```
|
||||
""";
|
||||
|
||||
Assert.That(SlideDeckSpec.TryParse(ANSWER, out var spec, out var issue), Is.True, issue);
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(spec.Title, Is.EqualTo("Mars"));
|
||||
Assert.That(spec.Slides, Has.Count.EqualTo(1));
|
||||
Assert.That(spec.Slides[0].Layout, Is.EqualTo("L3"));
|
||||
Assert.That(spec.Slides[0].Title, Is.EqualTo("Why Mars"));
|
||||
Assert.That(spec.Slides[0].Texts[0].Paragraphs, Is.EqualTo(new[] { new SlideParagraph("Close to Earth", 0), new SlideParagraph("Two years per trip", 1) }));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void WhatModelsWriteInSeveralWaysIsAccepted()
|
||||
{
|
||||
const string ANSWER = """
|
||||
{
|
||||
"title": "Mars",
|
||||
"slides": [
|
||||
{
|
||||
"layout": "L6",
|
||||
"starts_chapter": true,
|
||||
"title": "Costs",
|
||||
"subtitle": null,
|
||||
"texts": ["- First point\n- Second point"],
|
||||
"labels": [null, "Source: NASA"],
|
||||
"pictures": ["2"],
|
||||
"table": { "headers": ["Year", "Missions"], "rows": [[2024, 3], ["2025", null]] },
|
||||
},
|
||||
],
|
||||
}
|
||||
""";
|
||||
|
||||
Assert.That(SlideDeckSpec.TryParse(ANSWER, out var spec, out var issue), Is.True, issue);
|
||||
var slide = spec.Slides[0];
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(slide.StartsChapter, Is.True);
|
||||
Assert.That(slide.Subtitle, Is.Empty, "A null is as good as a missing value.");
|
||||
Assert.That(slide.Texts[0].Paragraphs, Is.EqualTo(new[] { new SlideParagraph("First point", 0), new SlideParagraph("Second point", 0) }), "One string with line breaks is one paragraph per line, and the template draws the bullets.");
|
||||
Assert.That(slide.Labels, Is.EqualTo(new[] { string.Empty, "Source: NASA" }), "An empty label keeps its place, so that the second one still lands in the second slot.");
|
||||
Assert.That(slide.Pictures, Is.EqualTo(new[] { 2 }));
|
||||
Assert.That(slide.Table?.Headers, Is.EqualTo(new[] { "Year", "Missions" }));
|
||||
Assert.That(slide.Table?.Rows, Is.EqualTo(new[] { new[] { "2024", "3" }, new[] { "2025", string.Empty } }), "Numbers in a table are text on a slide.");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AnAnswerWithoutJsonIsReported()
|
||||
{
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(SlideDeckSpec.TryParse("I cannot help with that.", out _, out var issue), Is.False);
|
||||
Assert.That(issue, Does.Contain("no complete JSON object"));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void BrokenJsonIsReported()
|
||||
{
|
||||
const string ANSWER = """{ "title": "Mars", "slides": [ { "layout": "L1",, "title": "Mars" } ] }""";
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(SlideDeckSpec.TryParse(ANSWER, out _, out var issue), Is.False);
|
||||
Assert.That(issue, Does.StartWith("The JSON object cannot be read"), "The reader's own message follows, which tells the model where the JSON breaks.");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TheOutlineFollowsTheReadingOrderOfEachLayout()
|
||||
{
|
||||
var deck = new SlideDeckSpec("Mars",
|
||||
[
|
||||
new SlideSpec("L2", true, "Getting there", "Part one", [], [], [], null),
|
||||
new SlideSpec("L5", false, "The rocket", string.Empty, [new SlideTextBlock([new SlideParagraph("Big", 0), new SlideParagraph("Very big", 1)])], [], [1], null),
|
||||
new SlideSpec("L6", false, "Costs", string.Empty, [], [], [], new SlideTableSpec(["Item", "Cost"], [["Fuel", "10"]])),
|
||||
]);
|
||||
|
||||
const string EXPECTED = """
|
||||
# Mars
|
||||
|
||||
***
|
||||
|
||||
## Getting there
|
||||
|
||||
_Part one_
|
||||
|
||||
***
|
||||
|
||||
### The rocket
|
||||
|
||||
- Big
|
||||
- Very big
|
||||
|
||||
*Picture: rocket.png*
|
||||
|
||||
***
|
||||
|
||||
### Costs
|
||||
|
||||
| Item | Cost |
|
||||
| --- | --- |
|
||||
| Fuel | 10 |
|
||||
""";
|
||||
|
||||
// The literal takes the line breaks of this file, which depend on the checkout:
|
||||
Assert.That(deck.ToMarkdownOutline(PresentationTemplate.CreateDefault(), new Dictionary<int, string> { [1] = "rocket.png" }), Is.EqualTo(EXPECTED.ReplaceLineEndings("\n")));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TheTemplateSlideAnEntryRefersToIsRead()
|
||||
{
|
||||
const string ANSWER = """
|
||||
{ "title": "Weekly", "slides": [
|
||||
{ "template_slide": 1 },
|
||||
{ "template_slide": "2", "title": "News", "texts": [["A new release"]] },
|
||||
{ "layout": "L3", "title": "Example" }
|
||||
] }
|
||||
""";
|
||||
|
||||
Assert.That(SlideDeckSpec.TryParse(ANSWER, out var spec, out var issue), Is.True, issue);
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(spec.Slides.Select(slide => slide.TemplateSlide), Is.EqualTo(new int?[] { 1, 2, null }));
|
||||
Assert.That(spec.Slides.Select(slide => slide.HasContent), Is.EqualTo(new[] { false, true, true }), "Only content of its own makes a slide more than a reference.");
|
||||
Assert.That(spec.Slides[0].Layout, Is.Empty);
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void WhatAnEditedSlideRemovesIsRead()
|
||||
{
|
||||
const string ANSWER = """
|
||||
{ "title": "Weekly", "slides": [
|
||||
{ "template_slide": 1, "remove": ["element 2", " label 1 ", 3, "Element 2", ""] },
|
||||
{ "template_slide": 2, "title": "News", "remove": "element 1" },
|
||||
{ "template_slide": 3 }
|
||||
] }
|
||||
""";
|
||||
|
||||
Assert.That(SlideDeckSpec.TryParse(ANSWER, out var spec, out var issue), Is.True, issue);
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(spec.Slides[0].Removals, Is.EqualTo(new[] { "element 2", "label 1", "3" }), "Every name counts once, whatever its case.");
|
||||
Assert.That(spec.Slides[1].Removals, Is.EqualTo(new[] { "element 1" }), "A single name is as good as a list.");
|
||||
Assert.That(spec.Slides[2].Removals, Is.Empty);
|
||||
Assert.That(spec.Slides.Select(slide => slide.HasContent), Is.EqualTo(new[] { true, true, false }), "Removing something changes a template slide as well.");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TheOutlineShowsWhatTheTemplateSlidesKeep()
|
||||
{
|
||||
using var file = TestTemplate.Create(
|
||||
new TestTemplateOptions(Slides:
|
||||
[
|
||||
new TestSlide(0, (TestPlaceholder.Title(), "Consultation hour"), (new TestPlaceholder(P.PlaceholderValues.SubTitle, 1), "Every Monday")),
|
||||
new TestSlide(1, (TestPlaceholder.Title(), "News"), (new TestPlaceholder(null, 1), "AI Studio:\nEINSTEIN:")),
|
||||
new TestSlide(1, (TestPlaceholder.Title(), "Questions"), (new TestPlaceholder(null, 1), "Ask us")),
|
||||
]),
|
||||
new TestLayout("Title", TestPlaceholder.Title(), TestPlaceholder.Subtitle(new SlotBounds(838_200, 3_602_038, 10_515_600, 1_655_762))),
|
||||
new TestLayout("Content", TestPlaceholder.Title(), TestPlaceholder.Text(1, TestTemplate.MASTER_BODY_BOUNDS)));
|
||||
|
||||
var deck = new SlideDeckSpec("Weekly",
|
||||
[
|
||||
new SlideSpec(string.Empty, false, string.Empty, string.Empty, [], [], [], null, 1),
|
||||
new SlideSpec(string.Empty, false, string.Empty, string.Empty, [new SlideTextBlock([new SlideParagraph("A new release", 0)])], [], [], null, 2),
|
||||
new SlideSpec(string.Empty, false, "Your questions", string.Empty, [], [], [], null, 3),
|
||||
]);
|
||||
|
||||
const string EXPECTED = """
|
||||
# Weekly
|
||||
|
||||
***
|
||||
|
||||
### Consultation hour
|
||||
|
||||
*Slide 1 of the template, unchanged*
|
||||
|
||||
_Every Monday_
|
||||
|
||||
***
|
||||
|
||||
### News
|
||||
|
||||
*Slide 2 of the template, edited*
|
||||
|
||||
- A new release
|
||||
|
||||
***
|
||||
|
||||
### Your questions
|
||||
|
||||
*Slide 3 of the template, edited*
|
||||
|
||||
- Ask us
|
||||
""";
|
||||
|
||||
Assert.That(deck.ToMarkdownOutline(PresentationTemplate.Read(file), new Dictionary<int, string>()), Is.EqualTo(EXPECTED.ReplaceLineEndings("\n")));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TheOutlineLeavesOutWhatAnEditedSlideRemoves()
|
||||
{
|
||||
using var file = TestTemplate.Create(
|
||||
new TestTemplateOptions(Slides: [new TestSlide(0, (TestPlaceholder.Title(), "Questions"), (new TestPlaceholder(null, 1), "Lorem ipsum"))]),
|
||||
new TestLayout("Content", TestPlaceholder.Title(), TestPlaceholder.Text(1, TestTemplate.MASTER_BODY_BOUNDS)));
|
||||
|
||||
var deck = new SlideDeckSpec("Weekly", [new SlideSpec(string.Empty, false, "Your questions", string.Empty, [], [], [], null, 1) { Removals = ["text 1"] }]);
|
||||
|
||||
const string EXPECTED = """
|
||||
# Weekly
|
||||
|
||||
***
|
||||
|
||||
### Your questions
|
||||
|
||||
*Slide 1 of the template, edited*
|
||||
""";
|
||||
|
||||
Assert.That(deck.ToMarkdownOutline(PresentationTemplate.Read(file), new Dictionary<int, string>()), Is.EqualTo(EXPECTED.ReplaceLineEndings("\n")));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TheOutlineShowsTheCopiesOfSlotsInReadingOrder()
|
||||
{
|
||||
using var file = TestTemplate.Create(new TestTemplateOptions(Slides: [StackedHeadings.Slide()]), StackedHeadings.Layout());
|
||||
|
||||
var deck = new SlideDeckSpec("Missions", [new SlideSpec(string.Empty, false, "Our missions", string.Empty, [new SlideTextBlock([new SlideParagraph("To Mars", 0)])], ["Mars"], [], null, 1) { Removals = ["label 3", "text 3"] }]);
|
||||
|
||||
const string EXPECTED = """
|
||||
# Missions
|
||||
|
||||
***
|
||||
|
||||
### Our missions
|
||||
|
||||
*Slide 1 of the template, edited*
|
||||
|
||||
_Mars_
|
||||
|
||||
- To Mars
|
||||
|
||||
_Lorem_
|
||||
|
||||
- Duis autem
|
||||
""";
|
||||
|
||||
Assert.That(deck.ToMarkdownOutline(PresentationTemplate.Read(file), new Dictionary<int, string>()), Is.EqualTo(EXPECTED.ReplaceLineEndings("\n")), "The second heading and its text keep the content of the template; the third ones are removed.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,517 @@
|
||||
using AIStudio.Tools.PowerPoint;
|
||||
|
||||
namespace AIStudio.Tests.Tools.PowerPoint;
|
||||
|
||||
/// <summary>
|
||||
/// Checks which slide decks are sent back to the model, and what it is told.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Uses the default catalog: L1 title slide, L2 section header, L3 title and content, L4 two
|
||||
/// content, L5 content with picture, and L6 table. Template slides need a template with slides,
|
||||
/// which TemplateWithSlides builds.
|
||||
/// </remarks>
|
||||
[TestFixture]
|
||||
public sealed class SlideDeckSpecValidatorTests
|
||||
{
|
||||
private static readonly PresentationTemplate TEMPLATE = PresentationTemplate.CreateDefault();
|
||||
|
||||
[Test]
|
||||
public void AFittingDeckHasNoIssues()
|
||||
{
|
||||
var deck = new SlideDeckSpec("Mars",
|
||||
[
|
||||
Slide("L1", "Mars", subtitle: "A plan"),
|
||||
Slide("L3", "Why Mars", texts: [Text("Close to Earth", "Has water ice", "Has a day of 24.6 hours")]),
|
||||
Slide("L5", "The rocket", texts: [Text("Reusable")], pictures: [1]),
|
||||
Slide("L6", "Costs", table: new SlideTableSpec(["Item", "Cost"], [["Fuel", "10"], ["Crew", "20"]])),
|
||||
]);
|
||||
|
||||
Assert.That(SlideDeckSpecValidator.Validate(deck, TEMPLATE, 1, 4), Is.Empty);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AnUnknownLayoutIsNamedWithTheOnesWhichExist()
|
||||
{
|
||||
var issues = SlideDeckSpecValidator.Validate(new SlideDeckSpec("Mars", [Slide("L9")]), TEMPLATE, 0, 0);
|
||||
|
||||
Assert.That(issues, Has.Some.Contains("uses the layout \"L9\", which does not exist. Use one of L1, L2, L3, L4, L5, L6."));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ContentWithoutASlotIsSentBack()
|
||||
{
|
||||
var deck = new SlideDeckSpec("Mars",
|
||||
[
|
||||
Slide("L3", texts: [Text("One"), Text("Two")]),
|
||||
Slide("L3", subtitle: "A subtitle"),
|
||||
Slide("L3", pictures: [1]),
|
||||
Slide("L3", table: new SlideTableSpec(["Item"], [["Fuel"]])),
|
||||
]);
|
||||
|
||||
var issues = SlideDeckSpecValidator.Validate(deck, TEMPLATE, 1, 0);
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(issues, Has.Some.Contains("Slide 1 has 2 texts, but layout L3 has only 1"));
|
||||
Assert.That(issues, Has.Some.Contains("Slide 2 has a subtitle, but layout L3 has none"));
|
||||
Assert.That(issues, Has.Some.Contains("Slide 3 has 1 picture, but layout L3 has no picture"));
|
||||
Assert.That(issues, Has.Some.Contains("Slide 4 has a table, but layout L3 has none"));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AnEmptyEntryKeepsItsSlotWithoutTakingOne()
|
||||
{
|
||||
var deck = new SlideDeckSpec("Mars",
|
||||
[
|
||||
Slide("L4", texts: [Text(), Text("Only the right side")]),
|
||||
Slide("L3", texts: [Text("One"), Text()]),
|
||||
]);
|
||||
|
||||
Assert.That(SlideDeckSpecValidator.Validate(deck, TEMPLATE, 0, 0), Is.Empty, "An empty text at the end takes no slot, so one text fits a layout with one.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TextClearlyBeyondTheSlotIsSentBack()
|
||||
{
|
||||
// The text slot of L3 holds about 9 lines at 28 pt. A little more is accepted, since the
|
||||
// template shrinks text which runs long; much more is not:
|
||||
var fitting = Slide("L3", texts: [Text(Enumerable.Range(1, 10).Select(number => $"Point {number}").ToArray())]);
|
||||
var tooLong = Slide("L3", texts: [Text(Enumerable.Range(1, 11).Select(number => $"Point {number}").ToArray())]);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(SlideDeckSpecValidator.Validate(new SlideDeckSpec("Mars", [fitting]), TEMPLATE, 0, 0), Is.Empty);
|
||||
Assert.That(SlideDeckSpecValidator.Validate(new SlideDeckSpec("Mars", [tooLong]), TEMPLATE, 0, 0), Has.Some.Contains("Slide 1: text 1 needs about 11 lines, but only about 9 fit"));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void APictureWhichIsNotAttachedIsSentBack()
|
||||
{
|
||||
var deck = new SlideDeckSpec("Mars", [Slide("L5", texts: [Text("Reusable")], pictures: [2])]);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(SlideDeckSpecValidator.Validate(deck, TEMPLATE, 1, 0), Has.Some.Contains("only pictures 1 to 1 are attached"));
|
||||
Assert.That(SlideDeckSpecValidator.Validate(deck, TEMPLATE, 0, 0), Has.Some.Contains("no pictures are attached"));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ARowWithTheWrongNumberOfCellsIsSentBack()
|
||||
{
|
||||
var deck = new SlideDeckSpec("Mars", [Slide("L6", table: new SlideTableSpec(["Item", "Cost"], [["Fuel", "10", "per ton"]]))]);
|
||||
|
||||
Assert.That(SlideDeckSpecValidator.Validate(deck, TEMPLATE, 0, 0), Has.Some.Contains("row 1 of the table has 3 cells, but the table has 2 columns"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TheNumberOfSlidesTheUserAskedForIsKept()
|
||||
{
|
||||
var deck = new SlideDeckSpec("Mars", [Slide("L3", texts: [Text("One")])]);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(SlideDeckSpecValidator.Validate(deck, TEMPLATE, 0, 3), Has.Some.Contains("exactly 3 were asked for"));
|
||||
Assert.That(SlideDeckSpecValidator.Validate(deck, TEMPLATE, 0, 0), Is.Empty, "0 leaves the number to the model.");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TemplateSlidesKeptAndEditedInTheirOrderHaveNoIssues()
|
||||
{
|
||||
var deck = new SlideDeckSpec("Weekly",
|
||||
[
|
||||
Keep(1),
|
||||
Slide("L2", "Agenda", texts: [Text("News", "Questions")]),
|
||||
Edit(2, "News", texts: [Text("AI Studio: a new release", "EINSTEIN: a new model")]),
|
||||
Keep(3),
|
||||
Edit(4, texts: [Text("Thema:\tWeekly", "Datum:\t2026-10-19")]),
|
||||
]);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(SlideDeckSpecValidator.Validate(deck, TemplateWithSlides(), 0, 5, USES), Is.Empty, "Every template slide counts as a slide of the presentation, an imprint included.");
|
||||
Assert.That(SlideDeckSpecValidator.Validate(deck, TemplateWithSlides(), 0, 4, USES), Has.Some.Contains("has 5 slides, but exactly 4 were asked for"));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void EveryChosenTemplateSlideAppearsExactlyOnce()
|
||||
{
|
||||
var deck = new SlideDeckSpec("Weekly", [Keep(1), Keep(1), Keep(5), Edit(4, texts: [Text("Thema:\tWeekly")])]);
|
||||
|
||||
var issues = SlideDeckSpecValidator.Validate(deck, TemplateWithSlides(), 0, 0, USES);
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(issues, Has.Some.Contains("Slide 3 refers to template slide 5, which is not offered. Refer only to template slides 1, 2, 3, 4."));
|
||||
Assert.That(issues, Has.Some.Contains("Template slide 1 appears 2 times."));
|
||||
Assert.That(issues, Has.Some.Contains("Template slide 2 is missing. Add it at its place with \"template_slide\": 2 and its new content."));
|
||||
Assert.That(issues, Has.Some.Contains("Template slide 3 is missing. Add it at its place as { \"template_slide\": 3 }."));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AKeptSlideTakesNoContentAndAnEditedOneNeedsContentWhichFits()
|
||||
{
|
||||
var deck = new SlideDeckSpec("Weekly",
|
||||
[
|
||||
Edit(1, "A new title"),
|
||||
Keep(2),
|
||||
Edit(3, "Questions", subtitle: "Ask us"),
|
||||
Edit(4, texts: [Text("Thema:\tWeekly")]),
|
||||
]);
|
||||
|
||||
var issues = SlideDeckSpecValidator.Validate(deck, TemplateWithSlides(), 0, 0, USES);
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(issues, Has.Some.Contains("Slide 1 keeps template slide 1 unchanged, so it takes no content."));
|
||||
Assert.That(issues, Has.Some.Contains("Slide 2 is to edit template slide 2, but writes no content."));
|
||||
Assert.That(issues, Has.Some.Contains("Slide 3 keeps template slide 3 unchanged"), "Slide 3 of the template is to be kept, so the edit is sent back.");
|
||||
});
|
||||
|
||||
var editedDeck = new SlideDeckSpec("Weekly", [Keep(1), Edit(2, "News", subtitle: "This week"), Keep(3), Edit(4, texts: [Text("Thema:\tWeekly")])]);
|
||||
Assert.That(SlideDeckSpecValidator.Validate(editedDeck, TemplateWithSlides(), 0, 0, USES), Has.Some.Contains("Slide 2 has a subtitle, but layout L2 has none"), "An edited slide has the slots of its template slide.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TheTemplateSlidesKeepTheirOrder()
|
||||
{
|
||||
var unordered = new SlideDeckSpec("Weekly", [Keep(3), Edit(2, "News", texts: [Text("New")]), Keep(1), Edit(4, texts: [Text("Thema:\tWeekly")])]);
|
||||
var imprintInBetween = new SlideDeckSpec("Weekly", [Keep(1), Edit(2, "News", texts: [Text("New")]), Keep(3), Edit(4, texts: [Text("Thema:\tWeekly")]), Slide("L2", "Thanks")]);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(SlideDeckSpecValidator.Validate(unordered, TemplateWithSlides(), 0, 0, USES), Has.Some.Contains("The template slides are out of order. Keep them in the order 1, 2, 3, 4"));
|
||||
Assert.That(SlideDeckSpecValidator.Validate(imprintInBetween, TemplateWithSlides(), 0, 0, USES), Is.Empty, "An imprint is a template slide like any other, so a new slide may follow it.");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void OnlySlidesOnKnownLayoutsAndChosenTemplateSlidesCanBeWritten()
|
||||
{
|
||||
var template = TemplateWithSlides();
|
||||
TemplateSlideUse[] uses = [TemplateSlideUse.KEEP, TemplateSlideUse.EDIT, TemplateSlideUse.OMIT, TemplateSlideUse.KEEP, TemplateSlideUse.KEEP];
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(SlideDeckSpecValidator.CanWrite(Slide("L2"), template, uses), Is.True);
|
||||
Assert.That(SlideDeckSpecValidator.CanWrite(Slide("L9"), template, uses), Is.False);
|
||||
Assert.That(SlideDeckSpecValidator.CanWrite(Keep(5), template, uses), Is.True, "A slide on a layout without room for content can be kept.");
|
||||
Assert.That(SlideDeckSpecValidator.CanWrite(Edit(5, "Thanks"), template, uses), Is.False, "Such a slide has no slots to write content into.");
|
||||
Assert.That(SlideDeckSpecValidator.CanWrite(Keep(3), template, uses), Is.False, "The user left this slide out.");
|
||||
Assert.That(SlideDeckSpecValidator.CanWrite(Keep(9), template, uses), Is.False);
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AnEditedSlideFitsTheSizeOfItsSlotOnTheSlide()
|
||||
{
|
||||
// The layout slot holds 10 lines of 50 characters; the slide halves it to 5 lines of 25:
|
||||
var bounds = new SlotBounds(838_200, 1_825_625, 6_413_500 + 2 * 91_440, 3_200_400 + 2 * 45_720);
|
||||
using var file = TestTemplate.Create(
|
||||
new TestTemplateOptions(Slides: [new TestSlide(0, (TestPlaceholder.Title(), "Rocket"), (TestPlaceholder.Text(1, bounds with { Width = bounds.Width / 2, Height = bounds.Height / 2 }), "Reusable"))]),
|
||||
new TestLayout("Content", TestPlaceholder.Title(), TestPlaceholder.Text(1, bounds, 2000)));
|
||||
|
||||
var template = PresentationTemplate.Read(file);
|
||||
var points = Text(Enumerable.Repeat(new string('x', 40), 4).ToArray());
|
||||
var edited = SlideDeckSpecValidator.Validate(new SlideDeckSpec("Mars", [Edit(1, texts: [points])]), template, 0, 0, [TemplateSlideUse.EDIT]);
|
||||
var written = SlideDeckSpecValidator.Validate(new SlideDeckSpec("Mars", [Slide("L1", texts: [points])]), template, 0, 0, [TemplateSlideUse.OMIT]);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(edited, Has.Some.Contains("Slide 1: text 1 needs about 8 lines, but only about 5 fit."), "Four points of two lines each overfill the half the slide leaves.");
|
||||
Assert.That(written, Is.Empty, "On a slide written anew, the same points fit the whole slot.");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AnEditedSlideRemovesOnlyWhatItsTemplateSlideHas()
|
||||
{
|
||||
using var file = TestTemplate.Create(
|
||||
new TestTemplateOptions(Slides:
|
||||
[
|
||||
new TestSlide(0, (TestPlaceholder.Title(), "News"), (TestPlaceholder.Label(17, new SlotBounds(838_200, 1_400_000, 3_000_000, 300_000)), "Lorem ipsum"), (new TestPlaceholder(null, 1), "Old news"))
|
||||
{
|
||||
Elements = [TestElement.TextBox(new SlotBounds(6_200_000, 5_400_000, 5_150_000, 300_000), "Bildquelle hier angeben")],
|
||||
},
|
||||
new TestSlide(0, (TestPlaceholder.Title(), "Questions")),
|
||||
]),
|
||||
new TestLayout("Content", TestPlaceholder.Title(), TestPlaceholder.Label(17, new SlotBounds(838_200, 1_400_000, 3_000_000, 300_000)), TestPlaceholder.Text(1, TestTemplate.MASTER_BODY_BOUNDS)));
|
||||
|
||||
var template = PresentationTemplate.Read(file);
|
||||
TemplateSlideUse[] uses = [TemplateSlideUse.EDIT, TemplateSlideUse.KEEP];
|
||||
IReadOnlyList<string> IssuesOf(SlideSpec first, SlideSpec? added = null) => SlideDeckSpecValidator.Validate(new SlideDeckSpec("Weekly", added is null ? [first, Keep(2)] : [first, Keep(2), added]), template, 0, 0, uses);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(IssuesOf(Edit(1) with { Removals = ["element 1", "label 1"] }), Is.Empty, "Removing alone edits a slide.");
|
||||
Assert.That(IssuesOf(Edit(1) with { Removals = ["element 2"] }), Has.Some.Contains("Slide 1 removes \"element 2\", which template slide 1 does not have."));
|
||||
Assert.That(IssuesOf(Edit(1) with { Removals = ["picture 1"] }), Has.Some.Contains("Slide 1 removes \"picture 1\", which template slide 1 does not have."));
|
||||
Assert.That(IssuesOf(Edit(1) with { Removals = ["title"] }), Has.Some.Contains("Slide 1 removes the title of template slide 1."));
|
||||
Assert.That(IssuesOf(Edit(1, texts: [Text("New news")]) with { Removals = ["text 1"] }), Has.Some.Contains("Slide 1 fills text 1 and removes it as well."));
|
||||
Assert.That(IssuesOf(Edit(1) with { Removals = ["element 1"] }, Slide("L1") with { Removals = ["element 1"] }), Has.Some.Contains("Slide 3 is a new slide, which has nothing to remove."));
|
||||
Assert.That(SlideDeckSpecValidator.Validate(new SlideDeckSpec("Weekly", [Edit(1) with { Removals = ["element 1"] }, Keep(2) with { Removals = ["element 1"] }]), template, 0, 0, uses), Has.Some.Contains("Slide 2 keeps template slide 2 unchanged, so it takes no content."));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AnEditedSlideFillsAndRemovesTheCopiesOfItsSlots()
|
||||
{
|
||||
using var file = TestTemplate.Create(new TestTemplateOptions(Slides: [StackedHeadings.Slide()]), StackedHeadings.Layout());
|
||||
|
||||
var template = PresentationTemplate.Read(file);
|
||||
IReadOnlyList<string> IssuesOf(SlideSpec slide) => SlideDeckSpecValidator.Validate(new SlideDeckSpec("Missions", [slide]), template, 0, 0, [TemplateSlideUse.EDIT]);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(IssuesOf(Edit(1, texts: [Text("Mars"), Text("Moon"), Text("Venus")], labels: ["Near", "Far", "Farther"])), Is.Empty, "Every copy takes content like a slot.");
|
||||
Assert.That(IssuesOf(Edit(1, texts: [Text("Mars"), Text("Moon"), Text("Venus"), Text("Jupiter")])), Has.Some.Contains("Slide 1 has 4 texts, but layout L1 has only 3."));
|
||||
Assert.That(IssuesOf(Edit(1, texts: [Text("Mars"), Text(new string('x', 120))])), Has.Some.Contains("Slide 1: text 2 needs about 5 lines, but only about 3 fit."), "A copy holds what its size allows.");
|
||||
Assert.That(IssuesOf(Edit(1, labels: ["Near", "Far"]) with { Removals = ["label 3", "text 3"] }), Is.Empty);
|
||||
Assert.That(IssuesOf(Edit(1, labels: ["Near"]) with { Removals = ["label 4"] }), Has.Some.Contains("Slide 1 removes \"label 4\", which template slide 1 does not have."));
|
||||
Assert.That(IssuesOf(Edit(1, texts: [Text("Mars"), Text("Moon")]) with { Removals = ["text 2"] }), Has.Some.Contains("Slide 1 fills text 2 and removes it as well."));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TheTitleSlideAndTheChaptersUseTheChosenLayouts()
|
||||
{
|
||||
var chosen = ChosenLayouts.Of(TEMPLATE, new Dictionary<LayoutPurpose, string>
|
||||
{
|
||||
[LayoutPurpose.TITLE] = "Title Slide",
|
||||
[LayoutPurpose.CHAPTER] = "Section Header",
|
||||
});
|
||||
|
||||
var fitting = new SlideDeckSpec("Mars", [Slide("L1", "Mars"), Slide("L2", "Why Mars", startsChapter: true), Slide("L3", "Water", texts: [Text("Ice at the poles")])]);
|
||||
var deviating = new SlideDeckSpec("Mars", [Slide("L3", "Mars"), Slide("L3", "Why Mars", texts: [Text("Close to Earth")], startsChapter: true)]);
|
||||
|
||||
var issues = SlideDeckSpecValidator.Validate(deviating, TEMPLATE, 0, 0, chosenLayouts: chosen);
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(SlideDeckSpecValidator.Validate(fitting, TEMPLATE, 0, 0, chosenLayouts: chosen), Is.Empty);
|
||||
Assert.That(issues, Has.Some.Contains("Slide 1 is the title slide, but uses layout L3. The title slide uses layout L1."));
|
||||
Assert.That(issues, Has.Some.Contains("Slide 2 starts a chapter, but uses layout L3. Chapters start with a slide on layout L2; put their content on the slides that follow."));
|
||||
Assert.That(SlideDeckSpecValidator.Validate(deviating, TEMPLATE, 0, 0), Is.Empty, "Without choices, the model decides every layout.");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TheSlidesWhichAChoiceAddsAreThereAndTheImprintComesLast()
|
||||
{
|
||||
var chosen = ChosenLayouts.Of(TEMPLATE, new Dictionary<LayoutPurpose, string>
|
||||
{
|
||||
[LayoutPurpose.TABLE_OF_CONTENTS] = "Two Content",
|
||||
[LayoutPurpose.IMPRINT] = "Table",
|
||||
});
|
||||
|
||||
var title = Slide("L1", "Mars");
|
||||
var contents = Slide("L4", "Contents", texts: [Text("Why Mars"), Text("How to get there")]);
|
||||
var imprint = Slide("L6", "Imprint", table: new SlideTableSpec(["Role", "Name"], [["Author", "Jane Doe"]]));
|
||||
var content = Slide("L3", "Why Mars", texts: [Text("Close to Earth")]);
|
||||
|
||||
var missing = SlideDeckSpecValidator.Validate(new SlideDeckSpec("Mars", [title, content]), TEMPLATE, 0, 0, chosenLayouts: chosen);
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(SlideDeckSpecValidator.Validate(new SlideDeckSpec("Mars", [title, contents, content, imprint]), TEMPLATE, 0, 0, chosenLayouts: chosen), Is.Empty);
|
||||
Assert.That(missing, Has.Some.Contains("The presentation has no slide on layout L4 for the table of contents, which lists the topics or chapters of the presentation without times. Add one right after the title slide."));
|
||||
Assert.That(missing, Has.Some.Contains("The presentation has no slide on layout L6 for the imprint"));
|
||||
Assert.That(SlideDeckSpecValidator.Validate(new SlideDeckSpec("Mars", [title, contents, imprint, content]), TEMPLATE, 0, 0, chosenLayouts: chosen), Has.Some.Contains("The imprint on layout L6 is not the last slide."));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ASlideHasNoMoreMainPointsThanAskedFor()
|
||||
{
|
||||
var points = Text("Close to Earth", "Has water ice");
|
||||
var morePoints = new SlideTextBlock([new SlideParagraph("Has a day of 24.6 hours", 0), new SlideParagraph("Close to the day on Earth", 1)]);
|
||||
var deck = new SlideDeckSpec("Mars", [Slide("L1", "Mars"), Slide("L4", "Why Mars", texts: [points, morePoints])]);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(SlideDeckSpecValidator.Validate(deck, TEMPLATE, 0, 0, maxMainPointsPerSlide: 3), Is.Empty, "A sub-point belongs to its main point.");
|
||||
Assert.That(SlideDeckSpecValidator.Validate(deck, TEMPLATE, 0, 0, maxMainPointsPerSlide: 2), Has.Some.Contains("Slide 2 has 3 main points, but at most 2 per slide were asked for, all its texts together."), "The texts of a slide count together.");
|
||||
Assert.That(SlideDeckSpecValidator.Validate(deck, TEMPLATE, 0, 0), Is.Empty, "Without a maximum, the model chooses.");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ATemplateSlideKeepsItsLayoutWhateverTheChoices()
|
||||
{
|
||||
var template = TemplateWithSlides();
|
||||
var chosen = ChosenLayouts.Of(template, new Dictionary<LayoutPurpose, string>
|
||||
{
|
||||
[LayoutPurpose.TITLE] = "Content",
|
||||
[LayoutPurpose.CHAPTER] = "Content",
|
||||
});
|
||||
|
||||
var deck = new SlideDeckSpec("Weekly",
|
||||
[
|
||||
Keep(1, startsChapter: true),
|
||||
Edit(2, "News", texts: [Text("AI Studio: a new release", "EINSTEIN: a new model")]),
|
||||
Keep(3, startsChapter: true),
|
||||
Edit(4, texts: [Text("Thema:\tWeekly")]),
|
||||
]);
|
||||
|
||||
Assert.That(SlideDeckSpecValidator.Validate(deck, template, 0, 0, USES, chosen), Is.Empty);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void EveryPictureForTheSlidesIsOnExactlyOneSlide()
|
||||
{
|
||||
var deck = new SlideDeckSpec("Mars",
|
||||
[
|
||||
Slide("L5", "The rocket", texts: [Text("Reusable")], pictures: [1]),
|
||||
Slide("L5", "The landing", texts: [Text("Soft")], pictures: [1]),
|
||||
]);
|
||||
|
||||
var issues = SlideDeckSpecValidator.Validate(deck, TEMPLATE, 2, 0);
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(issues, Has.Some.Contains("Picture 1 is on slides 1, 2. Place it on exactly one of them."));
|
||||
Assert.That(issues, Has.Some.Contains("Picture 2 is on no slide. Place every picture of PICTURES on exactly one slide with a picture slot."));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void PicturesNeedNoPlaceInATemplateWithoutPictureSlots()
|
||||
{
|
||||
var deck = new SlideDeckSpec("Weekly", [Slide("L2", texts: [Text("Close to Earth")])]);
|
||||
|
||||
Assert.That(SlideDeckSpecValidator.Validate(deck, TemplateWithSlides(), 2, 0), Is.Empty, "No repair round can find a place the template does not have.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void APictureOfTheLibraryIsUsedOnceButAnIconMayRepeat()
|
||||
{
|
||||
// Picture 1 is for the slides; pictures 2 to 4 come from the library, and 3 and 4 are icons:
|
||||
var library = new LibraryPictures(3, new HashSet<int> { 3, 4 });
|
||||
var deck = new SlideDeckSpec("Mars",
|
||||
[
|
||||
Slide("L5", "One", texts: [Text("Start")], pictures: [1]),
|
||||
Slide("L5", "Two", texts: [Text("Rocket")], pictures: [2]),
|
||||
Slide("L5", "Three", texts: [Text("Rocket again")], pictures: [2]),
|
||||
Slide("L5", "Four", texts: [Text("Risk")], pictures: [3]),
|
||||
Slide("L5", "Five", texts: [Text("Another risk")], pictures: [3]),
|
||||
Slide("L5", "Six", texts: [Text("Goal")], pictures: [4]),
|
||||
]);
|
||||
|
||||
var issues = SlideDeckSpecValidator.Validate(deck, TEMPLATE, 1, 0, libraryPictures: library);
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(issues, Has.Some.Contains("Picture 2 of PICTURE_LIBRARY is on slides 2, 3. Use a picture of the library at most once; only icons may repeat."));
|
||||
Assert.That(issues, Has.None.Contains("Picture 3 of PICTURE_LIBRARY"), "An icon may mark several slides.");
|
||||
Assert.That(issues, Has.None.Contains("refers to picture"), "The numbers of the library follow those of the pictures for the slides.");
|
||||
Assert.That(issues, Has.None.Contains("is on no slide"), "Only the pictures for the slides have to be used.");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AnIconNeverFillsABackdrop()
|
||||
{
|
||||
var template = TestTemplate.Read(
|
||||
new TestLayout("Backdrop", TestPlaceholder.Picture(11, new SlotBounds(0, 0, TestTemplate.SLIDE_WIDTH, TestTemplate.SLIDE_HEIGHT)), TestPlaceholder.Title()),
|
||||
new TestLayout("Picture with text", TestPlaceholder.Title(), TestPlaceholder.Text(1, new SlotBounds(838_200, 1_825_625, 5_150_000, 4_351_338)), TestPlaceholder.Picture(20, new SlotBounds(6_200_000, 1_825_625, 5_150_000, 3_500_000))));
|
||||
|
||||
// Both pictures come from the library, and the first one is an icon:
|
||||
var library = new LibraryPictures(2, new HashSet<int> { 1 });
|
||||
var deck = new SlideDeckSpec("Mars",
|
||||
[
|
||||
Slide("L1", "Chapter", pictures: [1]),
|
||||
Slide("L2", "Goal", texts: [Text("Reach Mars")], pictures: [1]),
|
||||
Slide("L1", "Landscape", pictures: [2]),
|
||||
]);
|
||||
|
||||
var issues = SlideDeckSpecValidator.Validate(deck, template, 0, 0, libraryPictures: library);
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(issues, Has.Some.Contains("Slide 1 puts icon 1 into picture 1, which is a backdrop."));
|
||||
Assert.That(issues, Has.None.Contains("Slide 2 puts icon"), "A smaller picture slot is the place for an icon.");
|
||||
Assert.That(issues, Has.None.Contains("Slide 3 puts icon"), "A photo fills a backdrop well.");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ADeckStillFitsATemplateWhichChangedBesideIt()
|
||||
{
|
||||
var deck = new SlideDeckSpec("Weekly", [Keep(1), Slide("L2", texts: [Text("Close to Earth")]), Edit(4, texts: [Text("Thema:\tWeekly")])]);
|
||||
var basis = TemplateWithSlides().DescribeBasisOf(deck);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(basis.Keys, Is.EquivalentTo(new[] { "template slide 1", "L2", "template slide 4" }), "An edited template slide fills the slots of its layout, a kept one does not.");
|
||||
Assert.That(SlideDeckSpecValidator.StillFits(deck, TemplateWithSlides(), basis), Is.True, "The same file, read once more.");
|
||||
Assert.That(SlideDeckSpecValidator.StillFits(deck, ChangedTemplateWithSlides(newLayoutAt: 3), basis), Is.True, "A new layout at the end of the slide master leaves the IDs of the others alone.");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ADeckNoLongerFitsWhenItsLayoutsOrTemplateSlidesMove()
|
||||
{
|
||||
var deck = new SlideDeckSpec("Weekly", [Keep(1), Slide("L2", texts: [Text("Close to Earth")]), Keep(3)]);
|
||||
var basis = TemplateWithSlides().DescribeBasisOf(deck);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(SlideDeckSpecValidator.StillFits(deck, ChangedTemplateWithSlides(newLayoutAt: 1), basis), Is.False, "L2 is another layout now.");
|
||||
Assert.That(SlideDeckSpecValidator.StillFits(deck, ChangedTemplateWithSlides(deletedSlide: 3), basis), Is.False, "Template slide 3 is the imprint now.");
|
||||
Assert.That(SlideDeckSpecValidator.StillFits(deck, ChangedTemplateWithSlides(deletedSlide: 3), new Dictionary<string, string>()), Is.False, "Without a basis, nothing is known to fit.");
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// What the user chose for the slides of TemplateWithSlides: the second slide and
|
||||
/// the imprint are edited, and the closing slide is left out.
|
||||
/// </summary>
|
||||
private static readonly TemplateSlideUse[] USES = [TemplateSlideUse.KEEP, TemplateSlideUse.EDIT, TemplateSlideUse.KEEP, TemplateSlideUse.EDIT, TemplateSlideUse.OMIT];
|
||||
|
||||
/// <summary>
|
||||
/// A template with a title slide, two content slides, an imprint, and a closing slide on a
|
||||
/// layout without room for content. Its families are L1 for the title and L2 for the content.
|
||||
/// </summary>
|
||||
private static PresentationTemplate TemplateWithSlides() => ChangedTemplateWithSlides();
|
||||
|
||||
/// <summary>
|
||||
/// The template of TemplateWithSlides after its author changed it.
|
||||
/// </summary>
|
||||
/// <param name="newLayoutAt">Where the slide master gets a new layout with room for content, starting at 0; null for no new layout.</param>
|
||||
/// <param name="deletedSlide">The number of the slide the author deleted; 0 for none.</param>
|
||||
private static PresentationTemplate ChangedTemplateWithSlides(int? newLayoutAt = null, int deletedSlide = 0)
|
||||
{
|
||||
List<TestLayout> layouts =
|
||||
[
|
||||
new("Title", TestPlaceholder.Title(), TestPlaceholder.Subtitle(new SlotBounds(838_200, 3_602_038, 10_515_600, 1_655_762))),
|
||||
new("Content", TestPlaceholder.Title(), TestPlaceholder.Text(1, TestTemplate.MASTER_BODY_BOUNDS)),
|
||||
new("Title Only", TestPlaceholder.Title()),
|
||||
];
|
||||
|
||||
List<TestSlide> slides =
|
||||
[
|
||||
new(0, (TestPlaceholder.Title(), "Consultation hour")),
|
||||
new(1, (TestPlaceholder.Title(), "News"), (new TestPlaceholder(null, 1), "AI Studio:\nEINSTEIN:")),
|
||||
new(1, (TestPlaceholder.Title(), "Questions")),
|
||||
new(1, (TestPlaceholder.Title(), "Impressum"), (new TestPlaceholder(null, 1), "Thema:\tConsultation hour")),
|
||||
new(2, (TestPlaceholder.Title(), "Thank you")),
|
||||
];
|
||||
|
||||
if (newLayoutAt is { } position)
|
||||
{
|
||||
layouts.Insert(position, new TestLayout("Imprint", TestPlaceholder.Title(), TestPlaceholder.Text(1, TestTemplate.MASTER_BODY_BOUNDS)));
|
||||
slides = [..slides.Select(slide => slide.Layout >= position ? slide with { Layout = slide.Layout + 1 } : slide)];
|
||||
}
|
||||
|
||||
if (deletedSlide > 0)
|
||||
slides.RemoveAt(deletedSlide - 1);
|
||||
|
||||
using var file = TestTemplate.Create(new TestTemplateOptions(Slides: slides), [..layouts]);
|
||||
return PresentationTemplate.Read(file);
|
||||
}
|
||||
|
||||
private static SlideSpec Keep(int templateSlide, bool startsChapter = false) => new(string.Empty, startsChapter, string.Empty, string.Empty, [], [], [], null, templateSlide);
|
||||
|
||||
private static SlideSpec Edit(int templateSlide, string title = "", string subtitle = "", IReadOnlyList<SlideTextBlock>? texts = null, IReadOnlyList<string>? labels = null) => new(string.Empty, false, title, subtitle, texts ?? [], labels ?? [], [], null, templateSlide);
|
||||
|
||||
private static SlideSpec Slide(string layout, string title = "Title", string subtitle = "", IReadOnlyList<SlideTextBlock>? texts = null, IReadOnlyList<int>? pictures = null, SlideTableSpec? table = null, bool startsChapter = false) => new(layout, startsChapter, title, subtitle, texts ?? [], [], pictures ?? [], table);
|
||||
|
||||
private static SlideTextBlock Text(params string[] paragraphs) => new(paragraphs.Select(paragraph => new SlideParagraph(paragraph, 0)).ToList());
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
using AIStudio.Tools.PowerPoint;
|
||||
|
||||
using P = DocumentFormat.OpenXml.Presentation;
|
||||
|
||||
namespace AIStudio.Tests.Tools.PowerPoint;
|
||||
|
||||
/// <summary>
|
||||
/// A layout with a heading above a text, and a slide on it which stacks three headings with their
|
||||
/// texts in the left half, beside a picture with its source, as authors of corporate templates do.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Only the first heading and the first text are placeholders. The others are text boxes copied
|
||||
/// from them: PowerPoint keeps the look of a copied placeholder, but not its link to the layout.
|
||||
/// Each heading sits closer to the text above it than to its own, as on the slides this was taken
|
||||
/// from. At 20 pt, the heading of the layout holds 1 line of 22 characters and its text 10 lines of
|
||||
/// 50 characters; each text of the slide holds 3 lines of 25 characters.
|
||||
/// </remarks>
|
||||
internal static class StackedHeadings
|
||||
{
|
||||
public static readonly SlotBounds HEADING = new(838_200, 1_400_000, 3_000_000, 300_000);
|
||||
|
||||
/// <summary>
|
||||
/// The text of the layout, sized for 10.5 lines and 50.5 characters at 20 pt, plus the default insets.
|
||||
/// </summary>
|
||||
public static readonly SlotBounds TEXT = new(838_200, 1_825_625, 6_413_500 + 2 * 91_440, 3_200_400 + 2 * 45_720);
|
||||
|
||||
public static readonly SlotBounds FIRST_TEXT = new(838_200, 1_825_625, 3_400_000, 1_000_000);
|
||||
public static readonly SlotBounds SECOND_HEADING = new(838_200, 2_850_000, 2_000_000, 300_000);
|
||||
public static readonly SlotBounds SECOND_TEXT = new(838_200, 3_275_625, 3_400_000, 1_000_000);
|
||||
public static readonly SlotBounds THIRD_HEADING = new(838_200, 4_300_000, 2_500_000, 300_000);
|
||||
public static readonly SlotBounds THIRD_TEXT = new(838_200, 4_725_625, 3_400_000, 1_000_000);
|
||||
public static readonly SlotBounds PICTURE = new(6_200_000, 1_825_625, 5_150_000, 3_500_000);
|
||||
public static readonly SlotBounds PICTURE_SOURCE = new(6_200_000, 5_375_625, 5_150_000, 300_000);
|
||||
|
||||
/// <summary>
|
||||
/// The color of the headings the author copied.
|
||||
/// </summary>
|
||||
public const string HEADING_FILL = "4472C4";
|
||||
|
||||
/// <summary>
|
||||
/// The layout with a heading above a text.
|
||||
/// </summary>
|
||||
/// <param name="name">The name of the layout, e.g., "B Content" for a color variant.</param>
|
||||
public static TestLayout Layout(string name = "Content with heading") => new(name, TestPlaceholder.Title(), TestPlaceholder.Label(17, HEADING, 2000), TestPlaceholder.Text(1, TEXT, 2000));
|
||||
|
||||
/// <summary>
|
||||
/// The slide stacking three headings with their texts: title, heading and text are the
|
||||
/// placeholders 2 to 4, the copies of heading and text the shapes 5 to 8, the picture 9 and its
|
||||
/// source 10.
|
||||
/// </summary>
|
||||
/// <param name="layout">The position of the layout in the template, starting at 0.</param>
|
||||
public static TestSlide Slide(int layout = 0) => new(layout,
|
||||
(TestPlaceholder.Title(), "Missions"),
|
||||
(new TestPlaceholder(P.PlaceholderValues.Body, 17), "Lorem ipsum"),
|
||||
(TestPlaceholder.Text(1, FIRST_TEXT), "Ut wisi enim"))
|
||||
{
|
||||
Elements =
|
||||
[
|
||||
TestElement.TextBox(SECOND_HEADING, "Lorem", HEADING_FILL),
|
||||
TestElement.TextBox(SECOND_TEXT, "Duis autem"),
|
||||
TestElement.TextBox(THIRD_HEADING, "Nam liber", HEADING_FILL),
|
||||
TestElement.TextBox(THIRD_TEXT, "Consectetuer"),
|
||||
TestElement.Picture(PICTURE, 1, 2, 3),
|
||||
TestElement.TextBox(PICTURE_SOURCE, "Bildquelle hier angeben"),
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using AIStudio.Tools.PowerPoint;
|
||||
|
||||
namespace AIStudio.Tests.Tools.PowerPoint;
|
||||
|
||||
/// <summary>
|
||||
/// Checks how the slides of an answer are counted while it streams in.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public sealed class StreamedSlideCounterTests
|
||||
{
|
||||
private const string ANSWER = """{"title": "Deck", "slides": [{"layout": "L1", "title": "Start"}, {"template_slide": 2}, {"layout": "L3", "title": "Results", "texts": [["All \"layout\" keys"]]}]}""";
|
||||
|
||||
[Test]
|
||||
public void SlidesOnLayoutsAndTemplateSlidesAreCounted()
|
||||
{
|
||||
Assert.That(new StreamedSlideCounter().Count(ANSWER), Is.EqualTo(3), "An escaped key within a text does not start a slide.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void EverySplitOfTheStreamGivesTheSameCount()
|
||||
{
|
||||
//
|
||||
// Wherever the chunks end, a key cut in two by them is counted once, and only once
|
||||
// it is complete:
|
||||
//
|
||||
for (var chunkLength = 1; chunkLength <= 20; chunkLength++)
|
||||
{
|
||||
var counter = new StreamedSlideCounter();
|
||||
var count = 0;
|
||||
for (var end = chunkLength; end < ANSWER.Length + chunkLength; end += chunkLength)
|
||||
count = counter.Count(ANSWER[..Math.Min(end, ANSWER.Length)]);
|
||||
|
||||
Assert.That(count, Is.EqualTo(3), $"Chunks of {chunkLength} characters");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AKeyCountsOnlyOnceItIsComplete()
|
||||
{
|
||||
var counter = new StreamedSlideCounter();
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(counter.Count("""{"slides": [{"lay"""), Is.Zero);
|
||||
Assert.That(counter.Count("""{"slides": [{"layout"""), Is.Zero, "The closing quotation mark is still missing.");
|
||||
Assert.That(counter.Count("""{"slides": [{"layout": "L1"}"""), Is.EqualTo(1));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AShorterAnswerIsCountedAnew()
|
||||
{
|
||||
var counter = new StreamedSlideCounter();
|
||||
counter.Count(ANSWER);
|
||||
|
||||
Assert.That(counter.Count("""{"slides": [{"layout": "L1"}"""), Is.EqualTo(1));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using AIStudio.Tools.PowerPoint;
|
||||
|
||||
namespace AIStudio.Tests.Tools.PowerPoint;
|
||||
|
||||
/// <summary>
|
||||
/// Checks how the choices for some slides of a template, e.g., the preselection of an organization, become a use for each slide.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public sealed class TemplateSlideUsesTests
|
||||
{
|
||||
[Test]
|
||||
public void EverySlideGetsAUseInTheOrderOfTheTemplate()
|
||||
{
|
||||
var template = TemplateWithAContentSlideAndAClosingSlide();
|
||||
var uses = new Dictionary<int, TemplateSlideUse> { [2] = TemplateSlideUse.KEEP }.ToTemplateSlideUses(template);
|
||||
|
||||
Assert.That(uses, Is.EqualTo(new[] { TemplateSlideUse.OMIT, TemplateSlideUse.KEEP }), "A slide without a choice is left out, as by default.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ASlideWithoutRoomForContentIsKeptInsteadOfEdited()
|
||||
{
|
||||
var template = TemplateWithAContentSlideAndAClosingSlide();
|
||||
var uses = new Dictionary<int, TemplateSlideUse> { [1] = TemplateSlideUse.EDIT, [2] = TemplateSlideUse.EDIT }.ToTemplateSlideUses(template);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(template.Slides.Select(slide => slide.IsEditable), Is.EqualTo(new[] { true, false }), "The test needs one slide which the model can edit and one which it cannot.");
|
||||
Assert.That(uses, Is.EqualTo(new[] { TemplateSlideUse.EDIT, TemplateSlideUse.KEEP }), "An organization may lock the choice, so it must not block the form.");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AChoiceForASlideTheTemplateLacksIsPassedOver()
|
||||
{
|
||||
var template = TemplateWithAContentSlideAndAClosingSlide();
|
||||
var uses = new Dictionary<int, TemplateSlideUse> { [0] = TemplateSlideUse.KEEP, [3] = TemplateSlideUse.EDIT }.ToTemplateSlideUses(template);
|
||||
|
||||
Assert.That(uses, Is.EqualTo(new[] { TemplateSlideUse.OMIT, TemplateSlideUse.OMIT }));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A template whose first slide uses a layout with room for content, and whose second slide uses one with a title only.
|
||||
/// </summary>
|
||||
private static PresentationTemplate TemplateWithAContentSlideAndAClosingSlide()
|
||||
{
|
||||
using var file = TestTemplate.Create(
|
||||
new TestTemplateOptions(Slides: [new TestSlide(0, (TestPlaceholder.Title(), "Mission")), new TestSlide(1, (TestPlaceholder.Title(), "Thank you"))]),
|
||||
new TestLayout("Content", TestPlaceholder.Title(), TestPlaceholder.Text(1, TestTemplate.MASTER_BODY_BOUNDS)),
|
||||
new TestLayout("Title Only", TestPlaceholder.Title()));
|
||||
|
||||
return PresentationTemplate.Read(file);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,532 @@
|
||||
using System.Text;
|
||||
|
||||
using AIStudio.Tools.PowerPoint;
|
||||
|
||||
using DocumentFormat.OpenXml;
|
||||
using DocumentFormat.OpenXml.Packaging;
|
||||
|
||||
using A = DocumentFormat.OpenXml.Drawing;
|
||||
using C = DocumentFormat.OpenXml.Drawing.Charts;
|
||||
using P = DocumentFormat.OpenXml.Presentation;
|
||||
using P14 = DocumentFormat.OpenXml.Office2010.PowerPoint;
|
||||
|
||||
namespace AIStudio.Tests.Tools.PowerPoint;
|
||||
|
||||
/// <summary>
|
||||
/// Builds small templates in memory, so that no test depends on the template file of an organization.
|
||||
/// </summary>
|
||||
internal static class TestTemplate
|
||||
{
|
||||
public const long SLIDE_WIDTH = 12_192_000;
|
||||
public const long SLIDE_HEIGHT = 6_858_000;
|
||||
|
||||
public const int MASTER_TITLE_FONT_SIZE = 4400;
|
||||
public const int MASTER_BODY_FONT_SIZE = 2800;
|
||||
|
||||
public static readonly SlotBounds MASTER_TITLE_BOUNDS = new(838_200, 365_125, 10_515_600, 1_325_563);
|
||||
public static readonly SlotBounds MASTER_BODY_BOUNDS = new(838_200, 1_825_625, 10_515_600, 4_351_338);
|
||||
|
||||
/// <summary>
|
||||
/// PowerPoint's own default table style, which the test template names as its default.
|
||||
/// </summary>
|
||||
public const string DEFAULT_TABLE_STYLE_ID = "{5C22544A-7EE6-4342-B048-85BDC9FD1C3A}";
|
||||
|
||||
/// <summary>
|
||||
/// The smallest theme the schema allows: PowerPoint opens no presentation without one.
|
||||
/// </summary>
|
||||
private const string THEME_XML = """
|
||||
<a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" name="Test">
|
||||
<a:themeElements>
|
||||
<a:clrScheme name="Test">
|
||||
<a:dk1><a:srgbClr val="000000"/></a:dk1>
|
||||
<a:lt1><a:srgbClr val="FFFFFF"/></a:lt1>
|
||||
<a:dk2><a:srgbClr val="44546A"/></a:dk2>
|
||||
<a:lt2><a:srgbClr val="E7E6E6"/></a:lt2>
|
||||
<a:accent1><a:srgbClr val="4472C4"/></a:accent1>
|
||||
<a:accent2><a:srgbClr val="ED7D31"/></a:accent2>
|
||||
<a:accent3><a:srgbClr val="A5A5A5"/></a:accent3>
|
||||
<a:accent4><a:srgbClr val="FFC000"/></a:accent4>
|
||||
<a:accent5><a:srgbClr val="5B9BD5"/></a:accent5>
|
||||
<a:accent6><a:srgbClr val="70AD47"/></a:accent6>
|
||||
<a:hlink><a:srgbClr val="0563C1"/></a:hlink>
|
||||
<a:folHlink><a:srgbClr val="954F72"/></a:folHlink>
|
||||
</a:clrScheme>
|
||||
<a:fontScheme name="Test">
|
||||
<a:majorFont><a:latin typeface="Arial"/><a:ea typeface=""/><a:cs typeface=""/></a:majorFont>
|
||||
<a:minorFont><a:latin typeface="Arial"/><a:ea typeface=""/><a:cs typeface=""/></a:minorFont>
|
||||
</a:fontScheme>
|
||||
<a:fmtScheme name="Test">
|
||||
<a:fillStyleLst>
|
||||
<a:solidFill><a:schemeClr val="phClr"/></a:solidFill>
|
||||
<a:solidFill><a:schemeClr val="phClr"/></a:solidFill>
|
||||
<a:solidFill><a:schemeClr val="phClr"/></a:solidFill>
|
||||
</a:fillStyleLst>
|
||||
<a:lnStyleLst>
|
||||
<a:ln w="6350"><a:solidFill><a:schemeClr val="phClr"/></a:solidFill></a:ln>
|
||||
<a:ln w="12700"><a:solidFill><a:schemeClr val="phClr"/></a:solidFill></a:ln>
|
||||
<a:ln w="19050"><a:solidFill><a:schemeClr val="phClr"/></a:solidFill></a:ln>
|
||||
</a:lnStyleLst>
|
||||
<a:effectStyleLst>
|
||||
<a:effectStyle><a:effectLst/></a:effectStyle>
|
||||
<a:effectStyle><a:effectLst/></a:effectStyle>
|
||||
<a:effectStyle><a:effectLst/></a:effectStyle>
|
||||
</a:effectStyleLst>
|
||||
<a:bgFillStyleLst>
|
||||
<a:solidFill><a:schemeClr val="phClr"/></a:solidFill>
|
||||
<a:solidFill><a:schemeClr val="phClr"/></a:solidFill>
|
||||
<a:solidFill><a:schemeClr val="phClr"/></a:solidFill>
|
||||
</a:bgFillStyleLst>
|
||||
</a:fmtScheme>
|
||||
</a:themeElements>
|
||||
</a:theme>
|
||||
""";
|
||||
|
||||
private const string SECTION_LIST_URI = "{521415D9-36F7-43E2-AB2F-B90AF26B5E84}";
|
||||
|
||||
/// <summary>
|
||||
/// Writes a presentation with one slide master and the given layouts.
|
||||
/// </summary>
|
||||
/// <param name="layouts">The layouts, in the order the master lists them.</param>
|
||||
/// <returns>The file, positioned at its start.</returns>
|
||||
public static MemoryStream Create(params TestLayout[] layouts) => Create(new TestTemplateOptions(), layouts);
|
||||
|
||||
/// <summary>
|
||||
/// Writes a presentation with one slide master and the given layouts.
|
||||
/// </summary>
|
||||
/// <param name="options">What else the file holds, and whether it is a template (.potx).</param>
|
||||
/// <param name="layouts">The layouts, in the order the master lists them.</param>
|
||||
/// <returns>The file, positioned at its start.</returns>
|
||||
public static MemoryStream Create(TestTemplateOptions options, params TestLayout[] layouts)
|
||||
{
|
||||
var stream = new MemoryStream();
|
||||
using (var document = PresentationDocument.Create(stream, options.AsTemplate ? PresentationDocumentType.Template : PresentationDocumentType.Presentation))
|
||||
{
|
||||
var presentationPart = document.AddPresentationPart();
|
||||
var masterPart = presentationPart.AddNewPart<SlideMasterPart>("rId1");
|
||||
var themePart = masterPart.AddNewPart<ThemePart>($"rId{layouts.Length + 2}");
|
||||
using (var theme = new MemoryStream(Encoding.UTF8.GetBytes(THEME_XML)))
|
||||
themePart.FeedData(theme);
|
||||
|
||||
var tableStylesPart = presentationPart.AddNewPart<TableStylesPart>("rId2");
|
||||
tableStylesPart.TableStyleList = new A.TableStyleList { Default = DEFAULT_TABLE_STYLE_ID };
|
||||
|
||||
var layoutParts = new List<SlideLayoutPart>();
|
||||
var layoutIds = new P.SlideLayoutIdList();
|
||||
for (var index = 0; index < layouts.Length; index++)
|
||||
{
|
||||
var relationshipId = $"rId{index + 2}";
|
||||
var layoutPart = masterPart.AddNewPart<SlideLayoutPart>(relationshipId);
|
||||
layoutPart.SlideLayout = new P.SlideLayout(
|
||||
new P.CommonSlideData(ShapeTreeOf(layouts[index].Placeholders)) { Name = layouts[index].Name },
|
||||
new P.ColorMapOverride(new A.MasterColorMapping()));
|
||||
|
||||
layoutPart.AddPart(masterPart);
|
||||
layoutParts.Add(layoutPart);
|
||||
layoutIds.Append(new P.SlideLayoutId { Id = 2_147_483_649U + (uint)index, RelationshipId = relationshipId });
|
||||
}
|
||||
|
||||
masterPart.SlideMaster = new P.SlideMaster(
|
||||
new P.CommonSlideData(ShapeTreeOf([
|
||||
new TestPlaceholder(P.PlaceholderValues.Title, null, MASTER_TITLE_BOUNDS),
|
||||
new TestPlaceholder(P.PlaceholderValues.Body, 1, MASTER_BODY_BOUNDS),
|
||||
])),
|
||||
new P.ColorMap
|
||||
{
|
||||
Background1 = A.ColorSchemeIndexValues.Light1,
|
||||
Text1 = A.ColorSchemeIndexValues.Dark1,
|
||||
Background2 = A.ColorSchemeIndexValues.Light2,
|
||||
Text2 = A.ColorSchemeIndexValues.Dark2,
|
||||
Accent1 = A.ColorSchemeIndexValues.Accent1,
|
||||
Accent2 = A.ColorSchemeIndexValues.Accent2,
|
||||
Accent3 = A.ColorSchemeIndexValues.Accent3,
|
||||
Accent4 = A.ColorSchemeIndexValues.Accent4,
|
||||
Accent5 = A.ColorSchemeIndexValues.Accent5,
|
||||
Accent6 = A.ColorSchemeIndexValues.Accent6,
|
||||
Hyperlink = A.ColorSchemeIndexValues.Hyperlink,
|
||||
FollowedHyperlink = A.ColorSchemeIndexValues.FollowedHyperlink,
|
||||
},
|
||||
layoutIds,
|
||||
new P.TextStyles(
|
||||
new P.TitleStyle(new A.Level1ParagraphProperties(new A.DefaultRunProperties { FontSize = MASTER_TITLE_FONT_SIZE })),
|
||||
new P.BodyStyle(new A.Level1ParagraphProperties(new A.DefaultRunProperties { FontSize = MASTER_BODY_FONT_SIZE })),
|
||||
new P.OtherStyle()));
|
||||
|
||||
//
|
||||
// Templates come with sample slides and hints, grouped into sections, which a
|
||||
// presentation written from the template must not keep:
|
||||
//
|
||||
var slideIds = new P.SlideIdList();
|
||||
var sectionSlideIds = new P14.SectionSlideIdList();
|
||||
for (var index = 0; index < options.ExampleSlides; index++)
|
||||
{
|
||||
var relationshipId = $"rId{100 + index}";
|
||||
var slidePart = presentationPart.AddNewPart<SlidePart>(relationshipId);
|
||||
slidePart.AddPart(layoutParts[0]);
|
||||
slidePart.Slide = new P.Slide(
|
||||
new P.CommonSlideData(ShapeTreeOf([TestPlaceholder.Title()])),
|
||||
new P.ColorMapOverride(new A.MasterColorMapping()));
|
||||
|
||||
slideIds.Append(new P.SlideId { Id = 256U + (uint)index, RelationshipId = relationshipId });
|
||||
sectionSlideIds.Append(new P14.SectionSlideIdListEntry { Id = 256U + (uint)index });
|
||||
}
|
||||
|
||||
// A presentation used as a template carries slides worth keeping, after the samples:
|
||||
var slides = options.Slides ?? [];
|
||||
var contentSectionSlideIds = new P14.SectionSlideIdList();
|
||||
for (var index = 0; index < slides.Count; index++)
|
||||
{
|
||||
var relationshipId = $"rId{200 + index}";
|
||||
var slidePart = presentationPart.AddNewPart<SlidePart>(relationshipId);
|
||||
slidePart.AddPart(layoutParts[slides[index].Layout]);
|
||||
|
||||
// The elements lie in front of the placeholders, with the IDs after theirs:
|
||||
var shapeTree = ShapeTreeOf(slides[index].Texts.Select(text => text.Placeholder).ToList(), slides[index].Texts.Select(text => text.Text).ToList());
|
||||
for (var elementIndex = 0; elementIndex < slides[index].Elements.Count; elementIndex++)
|
||||
shapeTree.Append(slides[index].Elements[elementIndex].Create((uint)(slides[index].Texts.Length + 2 + elementIndex), slidePart));
|
||||
|
||||
slidePart.Slide = new P.Slide(
|
||||
new P.CommonSlideData(shapeTree),
|
||||
new P.ColorMapOverride(new A.MasterColorMapping()));
|
||||
|
||||
if (slides[index].IsHidden)
|
||||
slidePart.Slide.Show = false;
|
||||
|
||||
var slideId = 256U + (uint)(options.ExampleSlides + index);
|
||||
slideIds.Append(new P.SlideId { Id = slideId, RelationshipId = relationshipId });
|
||||
contentSectionSlideIds.Append(new P14.SectionSlideIdListEntry { Id = slideId });
|
||||
}
|
||||
|
||||
presentationPart.Presentation = new P.Presentation(
|
||||
new P.SlideMasterIdList(new P.SlideMasterId { Id = 2_147_483_648U, RelationshipId = "rId1" }),
|
||||
slideIds,
|
||||
new P.SlideSize { Cx = (int)SLIDE_WIDTH, Cy = (int)SLIDE_HEIGHT },
|
||||
new P.NotesSize { Cx = 6_858_000, Cy = 9_144_000 });
|
||||
|
||||
if (options.ExampleSlides > 0)
|
||||
{
|
||||
var sections = new P14.SectionList(new P14.Section(sectionSlideIds) { Name = "Hints", Id = "{8A7F8F2C-6E59-4C61-9D3E-5B4D2D8F0A11}" });
|
||||
if (slides.Count > 0)
|
||||
sections.Append(new P14.Section(contentSectionSlideIds) { Name = "Slides", Id = "{3C1E6B0D-2F4A-4E8B-9C7D-1A5F6E2B8D40}" });
|
||||
|
||||
presentationPart.Presentation.PresentationExtensionList = new P.PresentationExtensionList(
|
||||
new P.PresentationExtension(sections) { Uri = SECTION_LIST_URI });
|
||||
}
|
||||
}
|
||||
|
||||
stream.Position = 0;
|
||||
return stream;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a presentation with the given layouts and reads it as a template.
|
||||
/// </summary>
|
||||
public static PresentationTemplate Read(params TestLayout[] layouts)
|
||||
{
|
||||
using var stream = Create(layouts);
|
||||
return PresentationTemplate.Read(stream);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The shape tree of a layout, a master or a slide.
|
||||
/// </summary>
|
||||
/// <param name="placeholders">The placeholders.</param>
|
||||
/// <param name="texts">The text of each placeholder, for a slide; a line break starts a new paragraph.</param>
|
||||
private static P.ShapeTree ShapeTreeOf(IReadOnlyList<TestPlaceholder> placeholders, IReadOnlyList<string>? texts = null)
|
||||
{
|
||||
var shapeTree = new P.ShapeTree(
|
||||
new P.NonVisualGroupShapeProperties(
|
||||
new P.NonVisualDrawingProperties { Id = 1U, Name = string.Empty },
|
||||
new P.NonVisualGroupShapeDrawingProperties(),
|
||||
new P.ApplicationNonVisualDrawingProperties()),
|
||||
new P.GroupShapeProperties(new A.TransformGroup()));
|
||||
|
||||
for (var index = 0; index < placeholders.Count; index++)
|
||||
shapeTree.Append(ShapeOf((uint)index + 2, placeholders[index], texts?[index] ?? string.Empty));
|
||||
|
||||
return shapeTree;
|
||||
}
|
||||
|
||||
private static P.Shape ShapeOf(uint id, TestPlaceholder placeholder, string text)
|
||||
{
|
||||
var placeholderShape = new P.PlaceholderShape();
|
||||
if (placeholder.Type is { } type)
|
||||
placeholderShape.Type = type;
|
||||
|
||||
if (placeholder.Index is { } index)
|
||||
placeholderShape.Index = index;
|
||||
|
||||
var shapeProperties = new P.ShapeProperties();
|
||||
if (placeholder.Bounds is { } bounds)
|
||||
shapeProperties.Transform2D = new A.Transform2D(
|
||||
new A.Offset { X = bounds.X, Y = bounds.Y },
|
||||
new A.Extents { Cx = bounds.Width, Cy = bounds.Height });
|
||||
|
||||
var listStyle = placeholder.FontSize is { } fontSize
|
||||
? new A.ListStyle(new A.Level1ParagraphProperties(new A.DefaultRunProperties { FontSize = fontSize }))
|
||||
: new A.ListStyle();
|
||||
|
||||
var textBody = new P.TextBody(new A.BodyProperties(), listStyle);
|
||||
if (text.Length == 0)
|
||||
textBody.Append(new A.Paragraph());
|
||||
else
|
||||
textBody.Append(text.Split('\n').Select(line => new A.Paragraph(new A.Run(new A.Text(line)))));
|
||||
|
||||
return new P.Shape(
|
||||
new P.NonVisualShapeProperties(
|
||||
new P.NonVisualDrawingProperties { Id = id, Name = $"Placeholder {id}" },
|
||||
new P.NonVisualShapeDrawingProperties(),
|
||||
new P.ApplicationNonVisualDrawingProperties(placeholderShape)),
|
||||
shapeProperties,
|
||||
textBody);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// What a test template holds beyond its layouts.
|
||||
/// </summary>
|
||||
/// <param name="ExampleSlides">How many sample slides the template carries, grouped into one section.</param>
|
||||
/// <param name="AsTemplate">Whether the file is a template (.potx) rather than a presentation.</param>
|
||||
/// <param name="Slides">The slides with content the template carries after the sample slides.</param>
|
||||
internal sealed record TestTemplateOptions(int ExampleSlides = 0, bool AsTemplate = false, IReadOnlyList<TestSlide>? Slides = null);
|
||||
|
||||
/// <summary>
|
||||
/// A slide of a test template, based on one of its layouts.
|
||||
/// </summary>
|
||||
/// <param name="Layout">The position of the layout in the template, starting at 0.</param>
|
||||
/// <param name="Texts">The placeholders of the slide with their texts; a line break starts a new paragraph. Placeholders without bounds take the place of the layout's.</param>
|
||||
internal sealed record TestSlide(int Layout, params (TestPlaceholder Placeholder, string Text)[] Texts)
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the slide is hidden in the slide show.
|
||||
/// </summary>
|
||||
public bool IsHidden { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The shapes of the slide which are no placeholders, in front of the placeholders.
|
||||
/// </summary>
|
||||
public IReadOnlyList<TestElement> Elements { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A shape of a test slide which is no placeholder, e.g., a table an author pasted onto the slide.
|
||||
/// </summary>
|
||||
/// <param name="Create">Writes the shape with the given ID for the given slide.</param>
|
||||
internal sealed record TestElement(Func<uint, SlidePart, OpenXmlElement> Create)
|
||||
{
|
||||
private const long ROW_HEIGHT = 370_840;
|
||||
|
||||
/// <summary>
|
||||
/// A table which is no placeholder, in equally wide columns; its first row is the header.
|
||||
/// </summary>
|
||||
public static TestElement Table(SlotBounds bounds, params string[][] rows) => new((id, _) => TableOf(id, null, bounds, rows));
|
||||
|
||||
/// <summary>
|
||||
/// A table which fills the table placeholder of the given index, as PowerPoint writes one.
|
||||
/// </summary>
|
||||
public static TestElement TablePlaceholder(uint index, SlotBounds bounds, params string[][] rows) => new((id, _) => TableOf(id, index, bounds, rows));
|
||||
|
||||
private static P.GraphicFrame TableOf(uint id, uint? placeholderIndex, SlotBounds bounds, string[][] rows)
|
||||
{
|
||||
var columns = rows[0].Length;
|
||||
var grid = new A.TableGrid();
|
||||
for (var column = 0; column < columns; column++)
|
||||
grid.Append(new A.GridColumn { Width = bounds.Width / columns });
|
||||
|
||||
var table = new A.Table(new A.TableProperties { FirstRow = true, BandRow = true }, grid);
|
||||
foreach (var row in rows)
|
||||
{
|
||||
var tableRow = new A.TableRow { Height = ROW_HEIGHT };
|
||||
foreach (var cell in row)
|
||||
tableRow.Append(new A.TableCell(new A.TextBody(new A.BodyProperties(), new A.ListStyle(), new A.Paragraph(new A.Run(new A.Text(cell)))), new A.TableCellProperties()));
|
||||
|
||||
table.Append(tableRow);
|
||||
}
|
||||
|
||||
var applicationProperties = new P.ApplicationNonVisualDrawingProperties();
|
||||
if (placeholderIndex is { } index)
|
||||
applicationProperties.PlaceholderShape = new P.PlaceholderShape { Type = P.PlaceholderValues.Table, Index = index };
|
||||
|
||||
return new P.GraphicFrame(
|
||||
new P.NonVisualGraphicFrameProperties(
|
||||
new P.NonVisualDrawingProperties { Id = id, Name = $"Table {id}" },
|
||||
new P.NonVisualGraphicFrameDrawingProperties(new A.GraphicFrameLocks { NoGrouping = true }),
|
||||
applicationProperties),
|
||||
new P.Transform(
|
||||
new A.Offset { X = bounds.X, Y = bounds.Y },
|
||||
new A.Extents { Cx = bounds.Width, Cy = ROW_HEIGHT * rows.Length }),
|
||||
new A.Graphic(new A.GraphicData(table) { Uri = "http://schemas.openxmlformats.org/drawingml/2006/table" }));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A text box: a shape with text which is no placeholder. Without text, it shows nothing.
|
||||
/// </summary>
|
||||
/// <param name="bounds">Where the text box sits.</param>
|
||||
/// <param name="text">Its text.</param>
|
||||
/// <param name="fill">The color it is filled with, e.g., "4472C4" for a heading copied from a colored placeholder; null for none.</param>
|
||||
public static TestElement TextBox(SlotBounds bounds, string text, string? fill = null) => new((id, _) =>
|
||||
{
|
||||
var properties = new P.ShapeProperties(TransformOf(bounds), new A.PresetGeometry(new A.AdjustValueList()) { Preset = A.ShapeTypeValues.Rectangle });
|
||||
if (fill is not null)
|
||||
properties.Append(new A.SolidFill(new A.RgbColorModelHex { Val = fill }));
|
||||
|
||||
return new P.Shape(
|
||||
new P.NonVisualShapeProperties(
|
||||
new P.NonVisualDrawingProperties { Id = id, Name = $"Text box {id}" },
|
||||
new P.NonVisualShapeDrawingProperties { TextBox = true },
|
||||
new P.ApplicationNonVisualDrawingProperties()),
|
||||
properties,
|
||||
new P.TextBody(new A.BodyProperties(), new A.ListStyle(), new A.Paragraph(new A.Run(new A.Text(text)))));
|
||||
});
|
||||
|
||||
/// <summary>
|
||||
/// A filled rectangle without text, e.g., a colored area behind a chart.
|
||||
/// </summary>
|
||||
public static TestElement Rectangle(SlotBounds bounds) => new((id, _) => new P.Shape(
|
||||
new P.NonVisualShapeProperties(
|
||||
new P.NonVisualDrawingProperties { Id = id, Name = $"Rectangle {id}" },
|
||||
new P.NonVisualShapeDrawingProperties(),
|
||||
new P.ApplicationNonVisualDrawingProperties()),
|
||||
new P.ShapeProperties(
|
||||
TransformOf(bounds),
|
||||
new A.PresetGeometry(new A.AdjustValueList()) { Preset = A.ShapeTypeValues.Rectangle },
|
||||
new A.SolidFill(new A.RgbColorModelHex { Val = "4472C4" })),
|
||||
new P.TextBody(new A.BodyProperties(), new A.ListStyle(), new A.Paragraph())));
|
||||
|
||||
/// <summary>
|
||||
/// A straight line.
|
||||
/// </summary>
|
||||
public static TestElement Line(SlotBounds bounds) => new((id, _) => new P.ConnectionShape(
|
||||
new P.NonVisualConnectionShapeProperties(
|
||||
new P.NonVisualDrawingProperties { Id = id, Name = $"Line {id}" },
|
||||
new P.NonVisualConnectorShapeDrawingProperties(),
|
||||
new P.ApplicationNonVisualDrawingProperties()),
|
||||
new P.ShapeProperties(
|
||||
TransformOf(bounds),
|
||||
new A.PresetGeometry(new A.AdjustValueList()) { Preset = A.ShapeTypeValues.Line },
|
||||
new A.Outline(new A.SolidFill(new A.RgbColorModelHex { Val = "000000" })))));
|
||||
|
||||
/// <summary>
|
||||
/// A picture which is no placeholder, with an image part of its own.
|
||||
/// </summary>
|
||||
public static TestElement Picture(SlotBounds bounds, params byte[] data) => new((id, slidePart) =>
|
||||
{
|
||||
var imagePart = slidePart.AddImagePart("image/png");
|
||||
using (var stream = new MemoryStream(data))
|
||||
imagePart.FeedData(stream);
|
||||
|
||||
return new P.Picture(
|
||||
new P.NonVisualPictureProperties(
|
||||
new P.NonVisualDrawingProperties { Id = id, Name = $"Picture {id}" },
|
||||
new P.NonVisualPictureDrawingProperties(new A.PictureLocks { NoChangeAspect = true }),
|
||||
new P.ApplicationNonVisualDrawingProperties()),
|
||||
new P.BlipFill(new A.Blip { Embed = slidePart.GetIdOfPart(imagePart) }, new A.Stretch(new A.FillRectangle())),
|
||||
new P.ShapeProperties(TransformOf(bounds), new A.PresetGeometry(new A.AdjustValueList()) { Preset = A.ShapeTypeValues.Rectangle }));
|
||||
});
|
||||
|
||||
/// <summary>
|
||||
/// A bar chart without data, with a chart part of its own.
|
||||
/// </summary>
|
||||
public static TestElement Chart(SlotBounds bounds) => new((id, slidePart) =>
|
||||
{
|
||||
var chartPart = slidePart.AddNewPart<ChartPart>();
|
||||
using (var stream = new MemoryStream(Encoding.UTF8.GetBytes(CHART_XML)))
|
||||
chartPart.FeedData(stream);
|
||||
|
||||
return new P.GraphicFrame(
|
||||
new P.NonVisualGraphicFrameProperties(
|
||||
new P.NonVisualDrawingProperties { Id = id, Name = $"Chart {id}" },
|
||||
new P.NonVisualGraphicFrameDrawingProperties(),
|
||||
new P.ApplicationNonVisualDrawingProperties()),
|
||||
new P.Transform(
|
||||
new A.Offset { X = bounds.X, Y = bounds.Y },
|
||||
new A.Extents { Cx = bounds.Width, Cy = bounds.Height }),
|
||||
new A.Graphic(new A.GraphicData(new C.ChartReference { Id = slidePart.GetIdOfPart(chartPart) }) { Uri = "http://schemas.openxmlformats.org/drawingml/2006/chart" }));
|
||||
});
|
||||
|
||||
/// <summary>
|
||||
/// Elements grouped into one, which keep their own places within the group.
|
||||
/// </summary>
|
||||
public static TestElement Group(SlotBounds bounds, params TestElement[] children) => new((id, slidePart) =>
|
||||
{
|
||||
var group = new P.GroupShape(
|
||||
new P.NonVisualGroupShapeProperties(
|
||||
new P.NonVisualDrawingProperties { Id = id, Name = $"Group {id}" },
|
||||
new P.NonVisualGroupShapeDrawingProperties(),
|
||||
new P.ApplicationNonVisualDrawingProperties()),
|
||||
new P.GroupShapeProperties(new A.TransformGroup(
|
||||
new A.Offset { X = bounds.X, Y = bounds.Y },
|
||||
new A.Extents { Cx = bounds.Width, Cy = bounds.Height },
|
||||
new A.ChildOffset { X = bounds.X, Y = bounds.Y },
|
||||
new A.ChildExtents { Cx = bounds.Width, Cy = bounds.Height })));
|
||||
|
||||
// The children take IDs of their own, apart from those of the slide's top-level shapes:
|
||||
for (var index = 0; index < children.Length; index++)
|
||||
group.Append(children[index].Create(id * 100 + (uint)index, slidePart));
|
||||
|
||||
return group;
|
||||
});
|
||||
|
||||
private const string CHART_XML = """
|
||||
<c:chartSpace xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
|
||||
<c:chart>
|
||||
<c:plotArea>
|
||||
<c:layout/>
|
||||
<c:barChart>
|
||||
<c:barDir val="col"/>
|
||||
<c:grouping val="clustered"/>
|
||||
<c:axId val="1"/>
|
||||
<c:axId val="2"/>
|
||||
</c:barChart>
|
||||
<c:catAx>
|
||||
<c:axId val="1"/>
|
||||
<c:scaling><c:orientation val="minMax"/></c:scaling>
|
||||
<c:delete val="0"/>
|
||||
<c:axPos val="b"/>
|
||||
<c:crossAx val="2"/>
|
||||
</c:catAx>
|
||||
<c:valAx>
|
||||
<c:axId val="2"/>
|
||||
<c:scaling><c:orientation val="minMax"/></c:scaling>
|
||||
<c:delete val="0"/>
|
||||
<c:axPos val="l"/>
|
||||
<c:crossAx val="1"/>
|
||||
</c:valAx>
|
||||
</c:plotArea>
|
||||
</c:chart>
|
||||
</c:chartSpace>
|
||||
""";
|
||||
|
||||
private static A.Transform2D TransformOf(SlotBounds bounds) => new(
|
||||
new A.Offset { X = bounds.X, Y = bounds.Y },
|
||||
new A.Extents { Cx = bounds.Width, Cy = bounds.Height });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A slide layout of a test template.
|
||||
/// </summary>
|
||||
internal sealed record TestLayout(string Name, params TestPlaceholder[] Placeholders);
|
||||
|
||||
/// <summary>
|
||||
/// A placeholder of a test layout; without bounds, it takes the place of the master placeholder.
|
||||
/// </summary>
|
||||
internal sealed record TestPlaceholder(P.PlaceholderValues? Type, uint? Index = null, SlotBounds? Bounds = null, int? FontSize = null)
|
||||
{
|
||||
public static TestPlaceholder Title() => new(P.PlaceholderValues.Title);
|
||||
|
||||
public static TestPlaceholder Subtitle(SlotBounds bounds) => new(P.PlaceholderValues.SubTitle, 1, bounds);
|
||||
|
||||
/// <summary>
|
||||
/// A content placeholder, which the file writes without a type.
|
||||
/// </summary>
|
||||
public static TestPlaceholder Text(uint index, SlotBounds bounds, int? fontSize = null) => new(null, index, bounds, fontSize);
|
||||
|
||||
public static TestPlaceholder Label(uint index, SlotBounds bounds, int? fontSize = null) => new(P.PlaceholderValues.Body, index, bounds, fontSize);
|
||||
|
||||
public static TestPlaceholder Picture(uint index, SlotBounds bounds) => new(P.PlaceholderValues.Picture, index, bounds);
|
||||
|
||||
public static TestPlaceholder Table(uint index, SlotBounds bounds) => new(P.PlaceholderValues.Table, index, bounds);
|
||||
|
||||
public static TestPlaceholder Footer(uint index, SlotBounds bounds) => new(P.PlaceholderValues.Footer, index, bounds);
|
||||
|
||||
public static TestPlaceholder SlideNumber(uint index, SlotBounds bounds) => new(P.PlaceholderValues.SlideNumber, index, bounds);
|
||||
}
|
||||
Reference in new issue
Block a user