The creator's ruling on the webcam's properties: a "Shape" title above the
Mirror/Rect row; the drag hint indented under that row "to show parentage";
the same for Geometry, Border Color, Border Thickness; Remove and Blur indented
under Background; Strength indented under Blur.
One rule covers it: a title owns its content and the content is indented one
level (10px) under it - two levels where a title is itself nested (Strength
under Blur under Background). The titles are SemiBold 11px #a0a0b0 like the
ones already there.
The shape also stops being a toggle button that shows the NEXT action ("Round"
when the camera was a rectangle) and becomes a CHOICE: two segment options,
Rect | Circle, with Rect selected by default - which is what the creator asked
for ("Rect should be selected as a default if none exists"). The old
ShapeButtonText is gone.
The mutual exclusion lives in the MODEL: IsRectShape/IsRoundShape on
SceneElement, each setter flipping the shape through ToggleClipShape() (so the
pre-round rect is still stored/restored) and always re-raising, so a refused
write - the vertical Chat layout is rect-only (TASK 57) - makes the UI snap
back instead of sticking. Deliberately NOT a WPF RadioButton group: a group
unchecks its siblings by setting IsChecked in code, which makes WPF drop the
binding (stackoverflow 7376373; the same trap measured in 50980687/57275046).
The Circle option stays hidden where CanToggleClipShape is false, so the circle
is still not offered in the vertical Chat layout.
Guard: ytLive.Tests/PropertiesHierarchyTests.cs - the model half (Rect by
default, pick circle, pick rect, the locked Chat layout refuses the circle) and
the panel half (the Shape title owns both options, the circle is the gated one,
the hint is indented and follows the row, every titled block's content is at
10px, Strength at 20px). 432/432 green, 0 warnings.
131 lines
6.5 KiB
C#
131 lines
6.5 KiB
C#
using System.Globalization;
|
|
using System.Linq;
|
|
using System.Xml.Linq;
|
|
using Xunit;
|
|
using ytLive.Models;
|
|
|
|
namespace ytLive.Tests;
|
|
|
|
/// <summary>TASK 59 — the creator's properties-panel hierarchy (2026-10-21):
|
|
/// "Mirror Rect: needs a title 'Shape' above it and Rect should be selected as a default
|
|
/// if none exists … the 'drag to move' help text needs to be indented under 'Mirror Rect'
|
|
/// to show parentage … same with Geometry, Border Color, Border Thickness … Remove should
|
|
/// be indented under Background as should Blur … Strength should be indented under Blur."
|
|
///
|
|
/// <para>One test, the whole story: the shape is a CHOICE and Rect is the selected
|
|
/// default (the model), the options live under a "Shape" title with the drag hint
|
|
/// indented beneath them, every titled block owns its content one indent deeper, and the
|
|
/// strength row sits under Blur — and the TASK 57 rule still holds: the circle option is
|
|
/// not offered in the vertical Chat layout, and the model refuses it there.</para></summary>
|
|
public class PropertiesHierarchyTests
|
|
{
|
|
[Fact]
|
|
public void The_webcam_properties_show_a_Shape_title_with_Rect_selected_and_indent_their_content()
|
|
{
|
|
// ── The model: the shape is a choice, and Rect is the default ─────────
|
|
var camera = new WebcamSceneConfig { WebcamId = "webcam-1", Name = "Webcam" };
|
|
Assert.True(camera.IsRectShape);
|
|
Assert.False(camera.IsRoundShape);
|
|
|
|
camera.IsRoundShape = true;
|
|
Assert.Equal(ClipShape.Round, camera.ClipShape);
|
|
Assert.False(camera.IsRectShape);
|
|
Assert.True(camera.IsRoundShape);
|
|
|
|
camera.IsRectShape = true;
|
|
Assert.Equal(ClipShape.Traditional, camera.ClipShape);
|
|
Assert.True(camera.IsRectShape);
|
|
Assert.False(camera.IsRoundShape);
|
|
|
|
// The vertical Chat layout locks the shape to a rect — the circle option is
|
|
// not offered there, and the model refuses it even if the UI asks.
|
|
camera.VerticalShapeLocked = true;
|
|
camera.IsRoundShape = true;
|
|
Assert.Equal(ClipShape.Traditional, camera.ClipShape);
|
|
Assert.False(camera.IsRoundShape);
|
|
Assert.True(camera.IsRectShape);
|
|
|
|
// ── The panel: a Shape title owns the options, the hint is indented ───
|
|
var doc = XDocument.Load(SourceAssert.RepoPath("Controls", "LeftPanel.xaml"));
|
|
|
|
var shapeTitle = doc.Descendants().Single(e => e.Name.LocalName == "TextBlock"
|
|
&& (string?)e.Attribute("Text") == "Shape");
|
|
Assert.Equal("SemiBold", (string?)shapeTitle.Attribute("FontWeight"));
|
|
|
|
var rectOption = OptionBoundTo(doc, "IsRectShape");
|
|
var circleOption = OptionBoundTo(doc, "IsRoundShape");
|
|
Assert.Contains("TwoWay", (string?)rectOption.Attribute("IsChecked") ?? "");
|
|
|
|
// The CIRCLE is the option that is not offered in the vertical Chat layout.
|
|
Assert.Contains("CanToggleClipShape", (string?)circleOption.Attribute("Visibility") ?? "");
|
|
|
|
// Both options live under the Shape title.
|
|
var shapeBlock = shapeTitle.Parent!;
|
|
Assert.Contains(rectOption, shapeBlock.Descendants());
|
|
Assert.Contains(circleOption, shapeBlock.Descendants());
|
|
|
|
// The drag hint is indented, and it comes after the shape row — it reads as
|
|
// part of the shape block.
|
|
var hint = doc.Descendants().Single(e => e.Name.LocalName == "TextBlock"
|
|
&& ((string?)e.Attribute("Text") ?? "").StartsWith("Drag to move"));
|
|
Assert.True(LeftIndent(hint) > 0, "the drag hint is not indented under the shape row");
|
|
var siblings = shapeBlock.Parent!.Elements().ToList();
|
|
Assert.True(siblings.IndexOf(shapeBlock) < siblings.IndexOf(hint),
|
|
"the drag hint comes before the shape row");
|
|
|
|
// ── Every titled block owns its content one indent deeper ────────────
|
|
var geometryTitle = doc.Descendants()
|
|
.Where(e => e.Name.LocalName == "TextBlock" && (string?)e.Attribute("Text") == "Geometry")
|
|
.Single(e => ((string?)e.Parent?.Attribute("Visibility") ?? "").Contains("IsWebcamSelected"));
|
|
Assert.Equal("SemiBold", (string?)geometryTitle.Attribute("FontWeight"));
|
|
foreach (var row in geometryTitle.Parent!.Elements().Where(e => e.Name.LocalName == "DockPanel"))
|
|
Assert.Equal(10, LeftIndent(row));
|
|
|
|
// The webcam block (the one the TASK 51 test finds by its binding).
|
|
var webcamPanel = doc.Descendants().Single(e => e.Name.LocalName == "StackPanel"
|
|
&& ((string?)e.Attribute("Visibility") ?? "").Contains("SelectedElement.IsWebcam"));
|
|
|
|
foreach (var title in new[] { "Border Color", "Border Thickness" })
|
|
{
|
|
var label = webcamPanel.Descendants().Single(e => e.Name.LocalName == "TextBlock"
|
|
&& (string?)e.Attribute("Text") == title);
|
|
Assert.Equal("SemiBold", (string?)label.Attribute("FontWeight"));
|
|
Assert.Equal(10, LeftIndent(RowAfter(label)));
|
|
}
|
|
|
|
// Remove and Blur are indented under Background; Strength one level deeper,
|
|
// under Blur.
|
|
Assert.Equal(10, LeftIndent(RowContaining(webcamPanel, "Remove")));
|
|
Assert.Equal(10, LeftIndent(RowContaining(webcamPanel, "Blur")));
|
|
Assert.Equal(20, LeftIndent(RowContaining(webcamPanel, "Strength")));
|
|
}
|
|
|
|
/// <summary>The shape option bound to the given model property.</summary>
|
|
private static XElement OptionBoundTo(XDocument doc, string property) =>
|
|
doc.Descendants().Single(e => e.Name.LocalName == "ToggleButton"
|
|
&& ((string?)e.Attribute("IsChecked") ?? "").Contains(property));
|
|
|
|
/// <summary>The row (DockPanel) that holds the given label.</summary>
|
|
private static XElement RowContaining(XElement scope, string label) =>
|
|
scope.Descendants().Single(e => e.Name.LocalName == "TextBlock"
|
|
&& (string?)e.Attribute("Text") == label).Parent!;
|
|
|
|
/// <summary>The row that follows a title.</summary>
|
|
private static XElement RowAfter(XElement title)
|
|
{
|
|
var next = title.ElementsAfterSelf().First(e => e.Name.LocalName == "DockPanel");
|
|
return next;
|
|
}
|
|
|
|
/// <summary>The left inset of an element's Margin, in pixels.</summary>
|
|
private static int LeftIndent(XElement element)
|
|
{
|
|
var margin = (string?)element.Attribute("Margin");
|
|
if (string.IsNullOrWhiteSpace(margin)) return 0;
|
|
var first = margin.Split(',')[0].Trim();
|
|
return int.TryParse(first, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value)
|
|
? value
|
|
: 0;
|
|
}
|
|
}
|