Lunchbox.psm1

# Behavior of 'Get-Lunchbox' is governed via settings exposed via
# 'Set-LunchboxConfiguration' configuration cmdlet to "hide" them
# somewhat, as they are mostly tangental to the goal of this module
# to demonstrate PowerShell objects and properties.
$lunchboxConfiguration = [PSCustomObject]@{
  UsePredefinedDataset        = $true
  PredefinedDatasetIndex      = 0
  AssignPropertiesAsObjects   = $true
  IncludeCalculatedProperties = $true
}

<#
.SYNOPSIS
   Modifies the behavior of 'Get-Lunchbox'.
.EXAMPLE
   Set-LunchboxConfiguration -UsePredefinedDataset:$false
 
   Subsequent use of 'Get-Lunchbox' will emit a random dataset.
.EXAMPLE
   Set-LunchboxConfiguration -AssignPropertiesAsObjects:$false
 
   Subsequent use of 'Get-Lunchbox' will assign property values cast
   to [string], demonstrating that while appearances re: type when using
   (e.g.) 'Format-List' can be deceiving, they don't *have* to be.
.INPUTS
   none
.OUTPUTS
   none
.NOTES
   This cmdlet does not apply a baseline configuration. Only aspects of
   configuration specified by parameter will be modified with each run.
#>

function Set-LunchboxConfiguration {
  [CmdletBinding(
    PositionalBinding = $false
  )]
  param(
    # Emit objects from a predefined dataset, to (e.g.) ensure consistency
    # when demonstrating function for users. Otherwise, 'Get-Lunchbox' will
    # emit a random dataset with each run.
    [switch]
    $UsePredefinedDataset,

    # When using a predefined dataset, governs which dataset (as defined in
    # module code) will be used.
    [byte]
    $PredefinedDatasetIndex,
    
    # 'Get-Lunchbox' assigns Nametag and various FoodItem properties as
    # objects. Otherwise, these properties on the emitted objects are
    # first cast to [string].
    [switch]
    $AssignPropertiesAsObjects,

    # Whether to calculate and show the sum of FoodItem.Cost and .Calories
    # on emitted objects.
    [switch]
    $IncludeCalculatedProperties
  )

  try {
    if ($PSBoundParameters.ContainsKey("UsePredefinedDataset")) {
      $script:lunchboxConfiguration.UsePredefinedDataset = [bool]$UsePredefinedDataset
    }

    if ($PSBoundParameters.ContainsKey("PredefinedDatasetIndex")) {
      $script:lunchboxConfiguration.PredefinedDatasetIndex = $PredefinedDatasetIndex
    }

    if ($PSBoundParameters.ContainsKey("AssignPropertiesAsObjects")) {
      $script:lunchboxConfiguration.AssignPropertiesAsObjects = [bool]$AssignPropertiesAsObjects
    }

    if ($PSBoundParameters.ContainsKey("IncludeCalculatedProperties")) {
      $script:lunchboxConfiguration.IncludeCalculatedProperties = [bool]$IncludeCalculatedProperties
    }
  } catch {
    $PSCmdlet.ThrowTerminatingError($_)
  }
}

$persons = [System.Collections.Generic.List[PSCustomObject]]::new()
$food = [ordered]@{
  MainItem = [System.Collections.Generic.List[PSCustomObject]]::new()
  Fruit    = [System.Collections.Generic.List[PSCustomObject]]::new()
  Snack    = [System.Collections.Generic.List[PSCustomObject]]::new()
  Beverage = [System.Collections.Generic.List[PSCustomObject]]::new()
}

$predefinedDatasets = [System.Collections.Generic.List[string]]::new()

$predefinedDatasets.Add(@"
1,17,3,8,2,1
2,25,7,4,6,2
3,28,7,8,0,4
4,6,5,6,6,0
5,31,2,2,1,0
6,3,2,4,8,2
7,26,5,1,7,3
8,2,1,8,7,4
9,10,4,7,6,1
10,21,2,8,4,3
11,33,3,2,5,2
12,29,8,3,1,3
13,5,1,8,6,4
14,16,1,5,7,0
15,13,6,8,1,1
16,34,4,7,8,1
17,24,8,5,5,1
18,14,6,5,0,4
19,20,8,1,8,4
20,22,1,0,7,2
21,32,8,0,5,2
22,8,8,3,2,4
23,11,0,5,8,4
24,1,5,5,2,1
25,19,4,7,2,4
26,12,6,4,8,2
27,15,4,1,7,4
28,4,0,4,7,0
29,9,2,6,2,1
30,7,5,7,8,3
31,23,5,1,0,0
32,0,0,2,0,3
33,30,7,5,4,4
34,18,5,0,6,4
35,27,3,1,0,1
"@
)
$predefinedDatasets.Add(@"
1,34,6,2,5,0
2,3,6,4,1,0
3,31,7,1,8,4
4,17,3,8,3,3
5,16,4,1,1,4
6,33,1,3,3,2
7,13,3,4,5,4
8,6,5,7,8,4
9,9,6,2,5,2
10,21,2,7,8,3
11,0,6,1,0,1
12,15,0,0,5,3
13,24,3,6,2,3
14,25,0,1,0,2
15,27,8,6,7,2
16,22,7,5,4,2
17,18,8,0,7,0
18,1,2,5,6,2
19,14,7,0,8,2
20,5,7,3,8,2
21,11,6,3,1,0
22,10,2,0,5,0
23,19,6,6,0,1
24,20,6,0,5,0
25,7,5,7,6,4
26,32,3,2,0,0
27,8,6,1,6,2
28,29,3,6,1,0
29,28,0,3,4,3
30,2,5,6,6,1
31,26,6,1,4,1
32,12,8,4,5,0
33,23,1,1,5,3
34,30,3,1,4,4
35,4,0,8,4,1
"@
)

function Internal_Add-LunchboxPerson {
  [CmdletBinding(
    PositionalBinding = $false
  )]
  [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseApprovedVerbs", "", Justification = "Internal implementation; not exposed.")] 
  param(
    [Parameter(
      Mandatory = $true
    )]
    [string]
    $FirstName,

    [Parameter()]
    [string]
    $MiddleName,

    [Parameter(
      Mandatory = $true
    )]
    [string]
    $LastName
  )

  $outObj = [PSCustomObject]@{
    Index      = $script:persons.Count
    FirstName  = $FirstName
    MiddleName = if ($PSBoundParameters.ContainsKey("MiddleName")) {
      $MiddleName
    } else {
      $null
    }
    LastName   = $LastName
  }

  $outObj | Add-Member -MemberType ScriptMethod -Name ToString -Value {
    @(
      $this.FirstName
      if ($null -ne $this.MiddleName) {
        $this.MiddleName[0] + "."
      }
      $this.LastName
    ) -join " "
  } -Force

  # Unfortunately, there is no way of modifying terms of default comparison (to
  # use LastNameFirstNameMiddleName for sorting, for example) without dipping
  # into PowerShell classes, or C# code.

  $outObj.psobject.TypeNames.Insert(0, "Lunchbox_psm1.LunchboxPerson")

  $script:persons.Add($outObj)
}
function Internal_Add-LunchboxFoodItem {
  [CmdletBinding(
    PositionalBinding = $false
  )]
  [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseApprovedVerbs", "", Justification = "Internal implementation; not exposed.")]
  param(
    [Parameter(
      Position = 0,
      Mandatory = $true
    )]
    [string]
    $Category,

    [Parameter(
      Position = 1,
      Mandatory = $true
    )]
    [string]
    $Name,

    [Parameter(
      Mandatory = $true
    )]
    [double]
    $Cost,

    [Parameter(
      Mandatory = $true
    )]
    [double]
    $Calories
  )

  $CostValue = $Cost | Add-Member -MemberType ScriptMethod -Name ToString -Value {"{0:c}" -f ([double]$this)} -Force -PassThru

  $outObj = [PSCustomObject]@{
    Category = $Category
    Index    = $script:food.$Category.Count
    Name     = $Name
    Cost     = $CostValue
    Calories = $Calories
  }

  $outObj | Add-Member -MemberType ScriptMethod -Name ToString -Value {$this.Name} -Force

  $outObj.psobject.TypeNames.Insert(0, "Lunchbox_psm1.LunchboxFoodItem")

  $script:food.$Category.Add($outObj)
}

function Internal_Assemble-Lunchbox {
  [CmdletBinding(
    PositionalBinding = $false
  )]
  [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseApprovedVerbs", "", Justification = "Internal implementation; not exposed.")]
  param(
    [Parameter(
      Mandatory = $true
    )]
    [string]
    $AssemblyString,

    [Parameter(
      Mandatory = $true
    )]
    [bool]
    $AssignPropertiesAsObjects,

    [Parameter(
      Mandatory = $true
    )]
    [bool]
    $IncludeCalculatedProperties
  )

  $AssemblyParts = $AssemblyString -split "," | ForEach-Object {[uint16]$_}

  $outObj = [PSCustomObject]@{
    OrderId = $AssemblyParts[0]
    Nametag = $script:persons[$AssemblyParts[1]]
    MainItem = $script:food.MainItem[$AssemblyParts[2]]
    Fruit = $script:food.Fruit[$AssemblyParts[3]]
    Snack = $script:food.Snack[$AssemblyParts[4]]
    Beverage = $script:food.Beverage[$AssemblyParts[5]]
  }

  $foodItemProperties = @(
    "MainItem",
    "Fruit",
    "Snack",
    "Beverage"
  )

  if ($IncludeCalculatedProperties) {
    $costValue = ($foodItemProperties | ForEach-Object {$outObj.$_} | Measure-Object Cost -Sum).Sum

    $costValue | Add-Member -MemberType ScriptMethod -Name ToString -Value {"{0:c}" -f ([double]$this)} -Force

    $outObj | Add-Member -NotePropertyMembers @{
      Cost     = $costValue
      Calories = ($foodItemProperties | ForEach-Object {$outObj.$_} | Measure-Object Calories -Sum).Sum
    }
  } 

  $propertiesAsObjects = @(
    "Nametag"
    $foodItemProperties
  )

  if (-not $AssignPropertiesAsObjects) {
    $propertiesAsObjects |
      ForEach-Object {
        $outObj.$_ = [string]($outObj.$_)
      }
  }

  $outObj.psobject.TypeNames.Insert(0, "Lunchbox_psm1.Lunchbox")

  if ($IncludeCalculatedProperties) {
    $outObj.psobject.TypeNames.Insert(0, "Lunchbox_psm1.LunchboxWithCalculatedProperties")
  }

  $outObj
}

#region define LunchboxPersons
New-Alias -Name p -Value Internal_Add-LunchboxPerson

# Cavaliers: Monarchs
p -FirstName Charles                     -LastName Stuart
p -FirstName Henrietta -MiddleName Maria -LastName Stuart

# Cavaliers: Political Leaders
p -FirstName George   -LastName Villiers  # Buckingham; Assassinated some time *before* the civil war, but a critical component of increasing civil discord.
p -FirstName Thomas   -LastName Wentworth # Strafford.
p -FirstName William  -LastName Laud
p -FirstName Edward   -LastName Hyde # Later Clarendon; Served Charles I & II. Grandfather of two queens through daughter's marriage to later James II.

# Cavaliers: Military Leaders
p -FirstName Robert                           -LastName Bertie # Lindsey; d. @ Edgehill.
p -FirstName George                           -LastName Goring
p -FirstName James     -MiddleName FitzThomas -LastName Butler
p -FirstName Alexander                        -LastName Leslie # Scot. Contra Charles I during the Bishops' Wars; opposed the parliamentarians later.
p -FirstName David                            -LastName Leslie # Scot.
# Unfortunately, no good first name / last name short-hand for Prince Rupert.
p -FirstName George                           -LastName Monck # Personalist loyalty to Oliver Cromwell; after his death, Monck was crucial to the restoration.

# Roundheads: Heads of Government
p -FirstName Oliver                      -LastName Cromwell
p -FirstName Richard                     -LastName Cromwell

# Roundheads: Maimed by William Laud
p -FirstName John                        -LastName Bastwick
p -FirstName Henry                       -LastName Burton
p -FirstName William                     -LastName Prynne

# Roundheads: Military Leaders (pre-New Model Army)
p -FirstName Robert                      -LastName Devereux # Essex.
p -FirstName Edward                      -LastName Montagu # Manchester.
p -FirstName John                        -LastName Hotham # Defiance of royal authority @ weapon depositry @ Kingston upon Hull.

# Roundheads: Military Leaders (prominent post-New Model Army)
p -FirstName Thomas                      -LastName Fairfax
p -FirstName John                        -LastName Lambert
p -FirstName George                      -LastName Joyce # "Cornet Joyce"; seizure of the king.
p -FirstName Thomas                      -LastName Pride # "Pride's Purge".

# Roundheads: "The Five Members"
p -FirstName John                        -LastName Hampden
p -FirstName Arthur                      -LastName Haselrig
p -FirstName Denzil                      -LastName Holles
p -FirstName John                        -LastName Pym
p -FirstName William                     -LastName Strode

# Roundheads: Parliamentary Leaders
p -FirstName William                     -LastName Lenthall # "...I have neither eyes to see nor tongue to speak..."
p -FirstName John                        -LastName Eliot

# Representatives of Dissident Factions
p -FirstName Thomas  -LastName Harrison   # Fifth Monarchist
p -FirstName John    -LastName Lilburne   # Leveller
p -FirstName Thomas  -LastName Rainsborough # Leveller. "The poorest he in England hath a life to live..."
p -FirstName Gerrard -LastName Winstanley # Leader / Pamphletist for the True Levellers ("Diggers")

Remove-Item -LiteralPath Alias:\p
#endregion

#region define LunchboxFoodItems
New-Alias -Name f -Value Internal_Add-LunchboxFoodItem

f MainItem "BLT Sandwich"             -Cost 2.50 -Calories 250
f MainItem "Chicken Parm Sandwich"    -Cost 6.25 -Calories 490
f MainItem "Egg Salad Sandwich"       -Cost 1.99 -Calories 200
f MainItem "Eggplant Parm Sandwich"   -Cost 4.00 -Calories 460
f MainItem "Ham & Cheese Sandwich"    -Cost 2.25 -Calories 200
f MainItem "Roast Beef Sandwich"      -Cost 3.00 -Calories 240
f MainItem "Salami Sandwich"          -Cost 3.00 -Calories 260
f MainItem "Tuna Sandwich"            -Cost 4.00 -Calories 300
f MainItem "Turkey & Cheese Sandwich" -Cost 3.00 -Calories 180

f Fruit "Apple"      -Cost 0.60 -Calories 120
f Fruit "Banana"     -Cost 1.20 -Calories 139
f Fruit "Cantaloupe" -Cost 0.52 -Calories 110
f Fruit "Grapes"     -Cost 0.80 -Calories 102
f Fruit "Orange"     -Cost 1.00 -Calories 111
f Fruit "Peach"      -Cost 1.10 -Calories 123
f Fruit "Pear"       -Cost 0.80 -Calories 132
f Fruit "Pineapple"  -Cost 1.25 -Calories 111
f Fruit "Watermelon" -Cost 0.65 -Calories 143

f Snack "Chocolate Chip Cookie" -Cost 0.25 -Calories 100
f Snack "No-Bake Cookie"        -Cost 0.50 -Calories 121
f Snack "Oatmeal Raisin Cookie" -Cost 0.75 -Calories 135
f Snack "Peanut Butter Cookie"  -Cost 0.63 -Calories 145
f Snack "Sugar Cookie"          -Cost 0.52 -Calories 153
f Snack "Potato Chips"          -Cost 1.25 -Calories 124
f Snack "Corn Chips"            -Cost 1.35 -Calories 152
f Snack "Cheese Crackers"       -Cost 0.75 -Calories 111
f Snack "Saltine Crackers"      -Cost 0.50 -Calories 123

f Beverage "Water"        -Cost 1.25 -Calories 0
f Beverage "Milk"         -Cost 2.00 -Calories 200
f Beverage "Orange Juice" -Cost 1.50 -Calories 250
f Beverage "Apple Juice"  -Cost 1.55 -Calories 225
f Beverage "Cola"         -Cost 1.20 -Calories 200

Remove-Item -LiteralPath Alias:\f
#endregion

<#
.SYNOPSIS
   Emits a collection of objects useful for demonstrating basic function of objects and properties in the pipeline.
.EXAMPLE
   Get-Lunchbox
.INPUTS
   none
.OUTPUTS
   lunchbox PSCustomObjects
.NOTES
   Cmdlet behavior is governed by settings controlled by
   'Set-LunchboxConfiguration'. Default behavior is well-suited to guided
   instruction of those new to PowerShell.
#>

function Get-Lunchbox {
  [CmdletBinding(
    PositionalBinding = $false
  )]
  param()
  try {
    Write-Verbose "Yeah, we don't really use Write-Verbose for anything interesting in this cmdlet. It means something that you tried, though."
    Write-Debug "Yeah, same with Write-Debug. It's a good sign that you're trying these things, though."

    if ($script:lunchboxConfiguration.UsePredefinedDataset -eq $true) {
      if ($null -eq $script:predefinedDatasets[$script:lunchboxConfiguration.PredefinedDatasetIndex]) {
        throw "Unused PredefinedDatasetIndex."
      }

      $AssemblyStrings = $script:predefinedDatasets[$script:lunchboxConfiguration.PredefinedDatasetIndex] -split "`n" | ForEach-Object Trim
    } elseif ($script:lunchboxConfiguration.UsePredefinedDataset -eq $false) { # Otherwise Random.
      $orderId = 1

      $AssemblyStrings = @(
        $script:persons |
          Get-Random -Count $script:persons.Count |
          ForEach-Object {
            @(
              ($orderId++)
              $_.Index
              ($script:food.MainItem | Get-Random).Index
              ($script:food.Fruit | Get-Random).Index
              ($script:food.Snack | Get-Random).Index
              ($script:food.Beverage | Get-Random).Index
            ) -join ","
          }
      )
    }

    $AssemblyStrings |
      ForEach-Object {
        Internal_Assemble-Lunchbox -AssemblyString $_ -AssignPropertiesAsObjects:$script:lunchboxConfiguration.AssignPropertiesAsObjects -IncludeCalculatedProperties:$script:lunchboxConfiguration.IncludeCalculatedProperties
      }
  } catch {
    $PSCmdlet.ThrowTerminatingError($_)
  }
}

<#
.SYNOPSIS
   Converts a collection of lunchbox PSCustomObjects to the [string] storage
   format used by datasets predefined in this module.
.EXAMPLE
   Get-Lunchbox |
     Convert-LunchboxToDatasetString
.INPUTS
   lunchbox PSCustomObjects
.OUTPUTS
   string
#>

function Convert-LunchboxToDatasetString {
  [CmdletBinding(
    PositionalBinding = $false
  )]
  param(
    [Parameter(
      ValueFromPipeline = $true,
      Mandatory = $true
    )]
    [PSTypeName("Lunchbox_psm1.Lunchbox")]
    [PSCustomObject[]]
    $Lunchbox
  )
  begin {
    $AssemblyStrings = [System.Collections.Generic.List[string]]::new()
  } process {
    try {
      $AssemblyParts = @(
        $Lunchbox.OrderId
        $Lunchbox.Nametag.Index
        $Lunchbox.MainItem.Index
        $Lunchbox.Fruit.Index
        $Lunchbox.Snack.Index
        $Lunchbox.Beverage.Index
      )
      
      if (@($AssemblyParts | Where-Object {$null -eq $_}).Count -gt 0) {
        throw "Missing / null 'Index' property in Lunchbox component. Ensure 'AssignPropertiesAsObjects' is set to true to retrieve objects compatible with conversion."
      }
      
      $AssemblyStrings.Add($AssemblyParts -join ",")
    } catch {
      $PSCmdlet.ThrowTerminatingError($_)
    }
  } end {
    $AssemblyStrings -join [System.Environment]::NewLine
  }
}