Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 83 additions & 2 deletions .github/workflows/ci-stress.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,19 @@ on:
options:
- unit
- selftests
- aot
- integration
- all
shards:
description: "Number of parallel runners (iterations are divided across shards)"
required: true
default: "20"
type: string
filter:
description: "Optional --self-test --filter prefix for the aot target (e.g. TTV_). Empty = full suite."
required: false
default: ""
type: string

jobs:
plan:
Expand Down Expand Up @@ -217,24 +223,99 @@ jobs:
}
Write-Host "Shard ${idx}: $count iterations passed."

aot-selftests:
name: AOT Selftests (shard ${{ matrix.shard }})
needs: plan
if: ${{ inputs.target == 'aot' || inputs.target == 'all' }}
runs-on: windows-latest
timeout-minutes: 350
strategy:
fail-fast: false
matrix:
shard: ${{ fromJSON(needs.plan.outputs.shard-list) }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

- name: Setup .NET
uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0
with:
dotnet-version: 10.0.x

# Publish the NativeAOT selftest host once per shard (same knobs as ci.yml).
- name: Publish AOT host (once)
run: >
dotnet publish tests/Reactor.AppTests.Host
-p:PublishAotInternal=true
-p:Platform=x64
-r win-x64
-c Release
-o ${{ runner.temp }}/aot-publish
--nologo

- name: Stress loop
shell: pwsh
env:
PER_SHARD: ${{ needs.plan.outputs.per-shard }}
REMAINDER: ${{ needs.plan.outputs.remainder }}
SHARD_INDEX: ${{ matrix.shard }}
FILTER: ${{ inputs.filter }}
run: |
$exe = Join-Path '${{ runner.temp }}/aot-publish' 'Reactor.AppTests.Host.exe'
if (-not (Test-Path $exe)) { Write-Host "::error::AOT host not found at $exe"; exit 1 }
$per = [int]$env:PER_SHARD
$rem = [int]$env:REMAINDER
$idx = [int]$env:SHARD_INDEX
$count = if ($idx -le $rem) { $per + 1 } else { $per }
if ($count -lt 1) { Write-Host "Shard $idx has no work."; exit 0 }
$args = @('--self-test')
if ($env:FILTER) { $args += @('--filter', $env:FILTER) }
$failures = New-Object System.Collections.Generic.List[int]
for ($i = 1; $i -le $count; $i++) {
Write-Host "::group::AOT selftest iteration $i / $count (shard $idx)"
& $exe @args 2>&1 | Tee-Object -FilePath "aot-$idx-$i.tap"
$code = $LASTEXITCODE
# A nonzero exit OR any 'not ok' line is a failure for this iteration.
$notOk = Select-String -Path "aot-$idx-$i.tap" -Pattern '^not ok ' -SimpleMatch -Quiet
Write-Host "::endgroup::"
if ($code -ne 0 -or $notOk) {
Write-Host "::warning::AOT iteration $i (shard $idx) failed (exit $code, notOk=$notOk)"
$failures.Add($i) | Out-Null
}
}
if ($failures.Count -gt 0) {
Write-Host "::error::Shard $idx had $($failures.Count) failed iteration(s): $($failures -join ', ')"
exit 1
}
Write-Host "Shard ${idx}: $count AOT iterations passed."

- name: Upload failing TAP
if: ${{ failure() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: aot-stress-tap-shard-${{ matrix.shard }}
path: aot-*.tap
if-no-files-found: ignore

summary:
name: Stress summary
needs: [plan, unit-tests, selftests, integration-tests]
needs: [plan, unit-tests, selftests, aot-selftests, integration-tests]
if: ${{ always() }}
runs-on: ubuntu-latest
steps:
- name: Report
env:
UNIT_RESULT: ${{ needs.unit-tests.result }}
SELFTESTS_RESULT: ${{ needs.selftests.result }}
AOT_RESULT: ${{ needs.aot-selftests.result }}
INTEGRATION_RESULT: ${{ needs.integration-tests.result }}
run: |
echo "Unit shards: $UNIT_RESULT"
echo "Selftest shards: $SELFTESTS_RESULT"
echo "AOT selftest shards: $AOT_RESULT"
echo "Integration shards: $INTEGRATION_RESULT"
fail=0
# 'skipped' is OK (target filter); 'success' is OK; anything else is a fail.
for r in "$UNIT_RESULT" "$SELFTESTS_RESULT" "$INTEGRATION_RESULT"; do
for r in "$UNIT_RESULT" "$SELFTESTS_RESULT" "$AOT_RESULT" "$INTEGRATION_RESULT"; do
case "$r" in
success|skipped|"") ;;
*) fail=1 ;;
Expand Down
105 changes: 66 additions & 39 deletions samples/Reactor.TestApp/Demos/DataTemplateDemo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ public override Element Render()

return ScrollView(VStack(16,
Heading("DataTemplate Demo"),
TextBlock("Typed ListView<T>, GridView<T>, FlipView<T> with viewBuilder, plus TreeView ContentElement."),
TextBlock("Typed ListView<T>, GridView<T>, FlipView<T> and TreeView<T> — all data-driven with a viewBuilder."),

// Filter + add/remove controls
HStack(12,
Expand Down Expand Up @@ -154,48 +154,75 @@ public override Element Render()

TextBlock($"Showing {flipIndex + 1} of {filtered.Count}").Foreground(SecondaryText),

// 4. TreeView with ContentElement
SubHeading("4. TreeView with ContentElement"),
TextBlock("Tree nodes render custom Reactor elements instead of plain text."),
// 4. Typed TreeView<T> — hierarchical peer of ListView<T>
SubHeading("4. Typed TreeView<T> with viewBuilder"),
TextBlock("Heterogeneous nodes render distinct templates via a switch in the viewBuilder (the ItemTemplateSelector pattern)."),
Border(
TreeView(
new TreeViewNodeData("Pets") { IsExpanded = true,
ContentElement = HStack(8,
TreeView(BuildPetTree(filtered),
keySelector: n => n.Key,
childrenSelector: n => n.Children.Length > 0 ? n.Children : null,
viewBuilder: n => n switch
{
PetRoot => HStack(8,
TextBlock("\U0001F3E0").FontSize(16),
TextBlock("All Pets").SemiBold()
),
Children = new[] { "Cat", "Dog", "Rabbit", "Hamster", "Parrot" }
.Where(species => filtered.Any(a => a.Species == species))
.Select(species => new TreeViewNodeData(species)
{
IsExpanded = true,
ContentElement = HStack(8,
TextBlock(species switch
{
"Cat" => "\U0001F431",
"Dog" => "\U0001F436",
"Rabbit" => "\U0001F430",
"Hamster" => "\U0001F439",
"Parrot" => "\U0001F99C",
_ => "\U0001F43E"
}),
TextBlock(species).SemiBold(),
TextBlock($"({filtered.Count(a => a.Species == species)})").Foreground(TertiaryText)
),
Children = filtered
.Where(a => a.Species == species)
.Select(a => new TreeViewNodeData(a.Name)
{
ContentElement = HStack(8,
TextBlock(a.Emoji),
TextBlock(a.Name),
Caption($"#{a.Id}").Foreground(TertiaryText)
)
}).ToArray()
}).ToArray()
}
)
).CornerRadius(8).Height(300)
PetSpecies s => HStack(8,
TextBlock(s.Emoji),
TextBlock(s.Species).SemiBold(),
TextBlock($"({s.Count})").Foreground(TertiaryText)
),
PetLeaf l => HStack(8,
TextBlock(l.Animal.Emoji),
TextBlock(l.Animal.Name),
Caption($"#{l.Animal.Id}").Foreground(TertiaryText)
),
_ => TextBlock("?")
})
// Expand every group; leaves have no children to expand.
with { IsExpanded = n => n is not PetLeaf }
).CornerRadius(8).Height(300).Margin(10)
));
}

// ── §4 typed-tree model: a discriminated pet hierarchy ────────────────
// (root group → species groups → animal leaves). Distinct record shapes
// drive distinct per-node templates in the viewBuilder switch above.
abstract record PetNode(string Key)
{
public PetNode[] Children { get; init; } = [];
}
record PetRoot(string Key) : PetNode(Key);
record PetSpecies(string Key, string Species, string Emoji, int Count) : PetNode(Key);
record PetLeaf(string Key, Animal Animal) : PetNode(Key);

static string EmojiForSpecies(string species) => species switch
{
"Cat" => "\U0001F431",
"Dog" => "\U0001F436",
"Rabbit" => "\U0001F430",
"Hamster" => "\U0001F439",
"Parrot" => "\U0001F99C",
_ => "\U0001F43E"
};

static PetNode[] BuildPetTree(List<Animal> animals)
{
var speciesGroups = new[] { "Cat", "Dog", "Rabbit", "Hamster", "Parrot" }
.Where(species => animals.Any(a => a.Species == species))
.Select(species => (PetNode)new PetSpecies(
$"species:{species}",
species,
EmojiForSpecies(species),
animals.Count(a => a.Species == species))
{
Children = animals
.Where(a => a.Species == species)
.Select(a => (PetNode)new PetLeaf($"animal:{a.Id}", a))
.ToArray()
})
.ToArray();

return [new PetRoot("root") { Children = speciesGroups }];
}
}
Loading
Loading