From 32bdc580e419691d4736ea953f06445c9a9958ce Mon Sep 17 00:00:00 2001 From: Brad Kent Date: Mon, 29 Dec 2025 11:29:55 -0600 Subject: [PATCH 01/37] Add new Table utility.. represent a table / build a table from data --- phpunit.xml.dist | 3 + src/Table/Element.php | 500 ++++++++++++++++++++ src/Table/Factory.php | 377 +++++++++++++++ src/Table/OutputHtml.php | 43 ++ src/Table/Table.php | 253 ++++++++++ src/Table/TableCell.php | 158 +++++++ src/Table/TableRow.php | 75 +++ tests/Table/ElementTest.php | 840 ++++++++++++++++++++++++++++++++++ tests/Table/FactoryTest.php | 750 ++++++++++++++++++++++++++++++ tests/Table/TableCellTest.php | 727 +++++++++++++++++++++++++++++ tests/Table/TableRowTest.php | 551 ++++++++++++++++++++++ tests/Table/TableTest.php | 752 ++++++++++++++++++++++++++++++ 12 files changed, 5029 insertions(+) create mode 100644 src/Table/Element.php create mode 100644 src/Table/Factory.php create mode 100644 src/Table/OutputHtml.php create mode 100644 src/Table/Table.php create mode 100644 src/Table/TableCell.php create mode 100644 src/Table/TableRow.php create mode 100644 tests/Table/ElementTest.php create mode 100644 tests/Table/FactoryTest.php create mode 100644 tests/Table/TableCellTest.php create mode 100644 tests/Table/TableRowTest.php create mode 100644 tests/Table/TableTest.php diff --git a/phpunit.xml.dist b/phpunit.xml.dist index e3e45f4a..599aab4d 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -83,6 +83,9 @@ tests/Slack + + tests/Table + tests/Teams diff --git a/src/Table/Element.php b/src/Table/Element.php new file mode 100644 index 00000000..0401046d --- /dev/null +++ b/src/Table/Element.php @@ -0,0 +1,500 @@ + */ + protected $attribs = array(); + + /** @var bool */ + protected $buildingHtml = false; + + /** @var list */ + protected $children = array(); + + /** @var array */ + protected $defaults = array( + 'tagName' => 'div', + ); + + /** @var string|null */ + protected $html = null; + + /** @var array */ + protected $meta = array(); + + /** @var Element|null */ + protected $parent = null; + + /** @var string */ + protected $tagName = 'div'; + + /** + * Constructor + * + * @param string $tagName Element's tagName (ie 'div', 'span', 'td', etc) + * @param list|string $childrenOrHtml Child elements or HTML content + */ + public function __construct($tagName, $childrenOrHtml = '') + { + $this->setTagName($tagName); + if (ArrayUtil::isList($childrenOrHtml)) { + $this->setChildren($childrenOrHtml); + return; + } + if (\is_array($childrenOrHtml)) { + // associative array of properties + foreach ($childrenOrHtml as $key => $val) { + $method = 'set' . \ucfirst($key); + $this->$method($val); + } + return; + } + $this->setHtml($childrenOrHtml); + } + + /** + * Serialize magic method + * (since php 7.4) + * + * @return array + */ + public function __serialize() + { + $data = array( + 'attribs' => $this->attribs, + 'children' => \array_map(static function (self $child) { + $data = $child->__serialize(); + if (\count($data) === 1) { + return \reset($data); + } + return $data; + }, $this->children), + 'html' => $this->html, + 'meta' => $this->meta, + 'tagName' => $this->tagName, + ); + $data = \array_filter($data, static function ($val) { + return $val !== null && $val !== []; + }); + return ArrayUtil::diffDeep($data, $this->defaults); + } + + /** + * Unserialize + * + * @param array $data serialized data + * + * @return void + */ + public function __unserialize(array $data) + { + foreach ($data as $key => $val) { + $method = 'set' . \ucfirst($key); + if (\method_exists($this, $method)) { + $this->$method($val); + } + } + } + + /** + * Get html attributes + * + * @return array + */ + public function getAttribs() + { + $defaultAttribs = isset($this->defaults['attribs']) + ? $this->defaults['attribs'] + : array(); + $attribs = ArrayUtil::mergeDeep($defaultAttribs, $this->attribs); + \ksort($attribs); + return $attribs; + } + + /** + * Get child elements + * + * @return array + */ + public function getChildren() + { + return $this->children; + } + + /** + * Get "inner" html content + * + * @return string|null + */ + public function getHtml() + { + $children = $this->getChildren(); + if ($children) { + $innerHtml = "\n" . \implode('', \array_map(static function (self $child) { + return $child->getOuterHtml() . "\n"; + }, $children)); + $innerHtml = \str_replace("\n", "\n ", $innerHtml); + $innerHtml = \substr($innerHtml, 0, -2); + return $innerHtml; + } + return $this->html; + } + + /** + * Get the index of this element amongst it's siblings + * + * @return int|null + */ + public function getIndex() + { + if ($this->parent === null) { + return null; + } + $siblings = $this->parent->getChildren(); + return \array_search($this, $siblings, true); + } + + /** + * Return element as html + * + * @return string + */ + public function getOuterHtml() + { + // get innerHTML first... may update attribs + $innerHtml = $this->getHtml(); + return Html::buildTag($this->getTagName(), $this->getAttribs(), $innerHtml); + } + + /** + * Get meta value(s) + * + * @param string $key key to get + * if not passed, return all meta values + * @param mixed $default (null) value to get + * + * @return mixed + */ + public function getMeta($key = null, $default = null) + { + if ($key === null) { + return $this->meta; + } + return \array_key_exists($key, $this->meta) + ? $this->meta[$key] + : $default; + } + + /** + * Get parent element + * + * @return Element|null; + */ + public function getParent() + { + return $this->parent; + } + + /** + * Get element's tag name (ie 'span', 'div', 'td', etc) + * + * @return string + */ + public function getTagName() + { + return $this->tagName; + } + + /** + * Get element's text content + * + * @return string + */ + public function getText() + { + $children = $this->getChildren(); + if ($children) { + return \implode('', \array_map(static function (self $child) { + return $child->getText(); + }, $children)); + } + return \htmlspecialchars_decode(\strip_tags($this->html)); + } + + /** + * Add class(es) to element + * + * @param string|list $class Class(es) to add + * + * @return $this + */ + public function addClass($class) + { + $newClasses = $this->normalizeClass($class); + $classesBefore = []; + if ($this->buildingHtml && isset($this->defaults['attribs']['class'])) { + $classesBefore = $this->defaults['attribs']['class']; + } elseif (isset($this->attribs['class'])) { + $classesBefore = $this->attribs['class']; + } + $classesAfter = \array_merge($classesBefore, $newClasses); + return $this->setAttrib('class', $classesAfter); + } + + /** + * Append child element + * + * @param self $child Child element + * + * @return $this + */ + public function appendChild(self $child) + { + $child->setParent($this); + $this->children[] = $child; + return $this; + } + + /** + * Remove class(es) from element + * + * @param string|list $class Class(es) to remove + * + * @return $this + */ + public function removeClass($class) + { + $removeClasses = $this->normalizeClass($class); + $classesBefore = \array_key_exists('class', $this->attribs) + ? $this->attribs['class'] + : []; + $classesAfter = \array_diff($classesBefore, $removeClasses); + return $this->setAttrib('class', $classesAfter); + } + + /** + * Update attribute value + * + * @param string $name Name + * @param mixed $value Value + * + * @return $this + */ + public function setAttrib($name, $value) + { + if ($name === 'class') { + $value = $this->normalizeClass($value); + } + $attribs = &$this->attribs; + if ($this->buildingHtml) { + $this->defaults = \array_merge(array( + 'attribs' => array(), + ), $this->defaults); + $attribs = &$this->defaults['attribs']; + } + $attribs[$name] = $value; + if ($name === 'class' && empty($value)) { + unset($attribs[$name]); + } + return $this; + } + + /** + * Set html attribute(s) + * + * @param array $attribs Attributes + * + * @return $this + */ + public function setAttribs(array $attribs) + { + \array_walk($attribs, function ($value, $key) { + $this->setAttrib($key, $value); + }); + return $this; + } + + /** + * Set children elements + * + * @param array $children Child elements + * + * @return $this + * + * @throws InvalidArgumentException + */ + public function setChildren(array $children) + { + foreach ($children as $child) { + if (($child instanceof self) === false) { + throw new InvalidArgumentException('Children must be instances of ' . __CLASS__); + } + $child->setParent($this); + } + $this->children = $children; + return $this; + } + + /** + * Set default values + * + * Default values will not be included when serializing + * + * @param array $defaults Default values + * + * @return $this + */ + public function setDefaults($defaults) + { + $this->defaults = $defaults; + return $this; + } + + /** + * Set html content + * + * @param string $html Html content + * + * @return $this + */ + public function setHtml($html) + { + $this->html = $html; + return $this; + } + + /** + * Set meta value(s) + * + * Value(s) get merged with existing values + * + * @param mixed $mixed (string) key or (array) key/value array + * @param mixed $val value if updating a single key + * + * @return $this + */ + public function setMeta($mixed, $val = null) + { + if (\is_array($mixed) === false) { + $mixed = array($mixed => $val); + } + $this->meta = \array_merge($this->meta, $mixed); + return $this; + } + + /** + * Set parent element + * + * @param Element|null $parent Parent element + * + * @return $this + * + * @throws InvalidArgumentException + */ + public function setParent($parent) + { + if ($parent !== null && ($parent instanceof Element) === false) { + throw new InvalidArgumentException('Parent must be instance of ' . __CLASS__ . ' (or null)'); + } + $this->parent = $parent; + return $this; + } + + /** + * Set the tagName of the element (ie 'div', 'span', 'td', etc) + * + * @param string $tagName Element's tagName + * + * @return $this + */ + public function setTagName($tagName) + { + $this->tagName = \strtolower($tagName); + return $this; + } + + /** + * Set text content + * + * @param string $text Text content + * + * @return $this + */ + public function setText($text) + { + $this->html = \htmlspecialchars($text); + return $this; + } + + /** + * Implements `JsonSerializable` + * + * @return array + */ + #[\ReturnTypeWillChange] + public function jsonSerialize() + { + $data = $this->__serialize(); + if (\array_keys($data) === ['children']) { + return $data['children']; + } + if (\array_keys($data) === ['html']) { + return $data['html']; + } + return $data; + } + + /** + * Implements `Serializable` + * + * @return string + */ + public function serialize() + { + return \serialize($this->__serialize()); + } + + /** + * Implements `Serializable` + * + * @param string $data serialized data + * + * @return void + */ + public function unserialize($data) + { + /** @var mixed */ + $unserialized = \unserialize($data); + if (\is_array($unserialized)) { + $this->__unserialize($unserialized); + } + } + + /** + * Normalize class value + * + * @param list|array $class Class(es) + * + * @return list + */ + private function normalizeClass($class) + { + if (\is_string($class)) { + $class = \explode(' ', $class); + } + $class = \array_unique(\array_filter($class)); + \sort($class); + return $class; + } +} diff --git a/src/Table/Factory.php b/src/Table/Factory.php new file mode 100644 index 00000000..b0b79d08 --- /dev/null +++ b/src/Table/Factory.php @@ -0,0 +1,377 @@ + */ + private $options = array( + 'columnLabels' => array( + self::KEY_CLASS_NAME => '', + self::KEY_INDEX => '', + self::KEY_SCALAR => 'value', + ), + 'columns' => [], + 'totalCols' => [], + ); + + /** @var array */ + private $meta = array( + 'class' => null, + // 'haveObjectRow' => false, + // 'isIndexed' => true, + 'columns' => array( + // array( + // 'class' => null, + // 'key' => int|string, + // 'total' => null, + // ) + ), + // 'rows' => array(), // key => array(class) + ); + + /** @var array */ + private $optionsDefault = array(); + + /** @var Table */ + private $table; + + /** + * Constructor + * + * @param array $options Default options + */ + public function __construct(array $options = array()) + { + $this->optionsDefault = \array_replace_recursive($this->options, $options); + } + + /** + * Create Table + * + * @param array|object $data Data to populate table + * @param array $options options + * + * @return Table + */ + public function create($data, array $options = array()) + { + $this->data = $data; + $this->table = new Table(); + $this->options = \array_replace_recursive($this->optionsDefault, $options); + $this->initMeta(); + $this->preProcess(); + // data is now array of arrays, + // keys/cols not yet determined + // keys/cols not necessarily in consistent order + $keys = $this->options['columns'] + ? \array_merge([self::KEY_INDEX], $this->options['columns']) + : $this->determineColumnKeys(); + $this->initMeta($keys); + $this->processRows($keys); + $this->addHeader(); + $this->addFooter(); + $this->meta['columns'] = \array_map(static function ($columnMeta) { + if (empty($columnMeta['class'])) { + unset($columnMeta['class']); + } + unset($columnMeta['total']); + return $columnMeta; + }, $this->meta['columns']); + $this->table->setMeta($this->meta); + $this->data = []; + return $this->table; + } + + /** + * Add header row to table + * + * @return void + */ + private function addFooter() + { + if (empty($this->options['totalCols'])) { + return; + } + $footerCells = array(); + foreach ($this->meta['columns'] as $columnMeta) { + $key = $columnMeta['key']; + $footerCells[] = \in_array($key, $this->options['totalCols'], true) + ? new TableCell($columnMeta['total']) + : (new TableCell())->setHtml(''); + } + $this->table->setFooter(new TableRow($footerCells)); + } + + /** + * Add footer row to table + * + * @return void + */ + private function addHeader() + { + $headerCells = array(); + foreach ($this->meta['columns'] as $columnMeta) { + $key = $columnMeta['key']; + $label = isset($this->options['columnLabels'][$key]) + ? $this->options['columnLabels'][$key] + : $key; + $headerCells[] = new TableCell($label); + } + $this->table->setHeader(new TableRow($headerCells)); + } + + /** + * Initialize temporary meta info + * + * @param array $keys column keys + * + * @return void + */ + private function initMeta(array $keys = []) + { + if (empty($keys)) { + $this->meta = array( + 'class' => null, // if table derived from object, store class name here + 'columns' => array( + // array( + // 'class' => null, + // 'key' => int|string, + // 'total' => null, + // ) + ), + // 'haveObjectRow' => false, + // 'isIndexed' => true, + // 'rows' => array(), + ); + } + foreach ($keys as $key) { + $this->meta['columns'][] = array( + 'class' => null, // if all values in column are objects of same class, store class name here + 'key' => $key, + 'total' => null, + ); + } + } + + /** + * Get object values as key->value array + * + * @param object $obj Object + * + * @return array + */ + private function getObjectValues($obj) + { + $vals = array(); + foreach ($obj as $k => $v) { + $vals[$k] = $v; + } + return $vals; + } + + /** + * Get object values as key->value array while specifying keys + * + * @param object $obj Object + * @param array $keys Keys to retrieve + * + * @return array + */ + private function getObjectValuesKeys($obj, $keys = []) + { + $vals = array(); + foreach ($keys as $key) { + try { + $vals[$key] = $obj->{$key}; + } catch (\Throwable $e) { + $vals[$key] = self::VAL_UNDEFINED; + } + } + return $vals; + } + + /** + * Get row values + * + * @param mixed $row Row (object, array, or scalar value) + * @param int|string $key Row's index/key + * + * @return array key->value array + */ + private function getRowValues($row, $key) + { + $isObject = false; + $type = PhpType::getDebugType($row, 0, $isObject); + if ($type === 'array') { + return \array_replace(array( + self::KEY_INDEX => $key, + ), $row); + } + if ($isObject) { + // object (but not UnitEnum or Closure) + $values = $this->options['columns'] + ? $this->getObjectValuesKeys($row, $this->options['columns']) + : $this->getObjectValues($row); + // @phpcs:ignore SlevomatCodingStandard.Arrays.AlphabeticallySortedByKeys + return \array_replace(array( + self::KEY_INDEX => $key, + self::KEY_CLASS_NAME => \get_class($row), + ), $values); + } + return array(self::KEY_SCALAR => $row); + } + + /** + * Determine column keys and their order + * + * @return list + */ + private function determineColumnKeys() + { + $colKeys = array(); + foreach ($this->data as $row) { + $curRowKeys = \array_keys($row); + if ($curRowKeys !== $colKeys) { + $colKeys = self::colKeysMerge($curRowKeys, $colKeys); + } + } + return $colKeys; + } + + /** + * Merge current row's keys with merged keys + * + * @param list $curRowKeys current row's keys + * @param list $colKeys all col keys + * + * @return list + */ + private static function colKeysMerge(array $curRowKeys, array $colKeys) + { + /** @var list */ + $newKeys = array(); + $count = \count($curRowKeys); + for ($i = 0; $i < $count; $i++) { + $curKey = $curRowKeys[$i]; + $position = \array_search($curKey, $colKeys, true); + if ($position !== false) { + $segment = \array_splice($colKeys, 0, (int) $position + 1); + /** @psalm-var list $newKeys */ + \array_splice($newKeys, \count($newKeys), 0, $segment); + } elseif (\in_array($curKey, $newKeys, true) === false) { + /** @psalm-var list $newKeys */ + $newKeys[] = $curKey; + } + } + // put on remaining colKeys + \array_splice($newKeys, \count($newKeys), 0, $colKeys); + /** @psalm-var list */ + return \array_values(\array_unique($newKeys)); + } + + /** + * Convert data to array of arrays + * + * @return void + */ + private function preProcess() + { + if (\is_object($this->data)) { + $this->meta['class'] = \get_class($this->data); + $this->data = $this->getObjectValues($this->data); + } + // $data is now array of unknowns (objects, arrays, or scalar) + foreach ($this->data as $key => $row) { + $this->data[$key] = $this->getRowValues($row, $key); + } + } + + /** + * Process data... add table rows + * + * @param array $keys column keys + * + * @return void + */ + private function processRows(array $keys) + { + $defaultValues = \array_fill_keys($keys, self::VAL_UNDEFINED); + foreach ($this->data as $row) { + $values = \array_replace($defaultValues, \array_intersect_key($row, $defaultValues)); + $this->updateRowMeta($values); + $row = new TableRow(ArrayUtil::mapWithKeys(static function ($val, $key) { + $cell = new TableCell($val); + if ($key === Factory::KEY_INDEX) { + $cell->setTagName('th') + ->addClass('t_key') + ->setAttrib('scope', 'row'); + } + return $cell; + }, $values)); + $this->table->appendRow($row); + } + } + + /** + * Collect column meta info + * + * + Test if column values are all of same class + * + total values for columns that require it + * + * @param array $values Row key=>value array + * + * @return void + */ + private function updateRowMeta(array $values) + { + $values = \array_values($values); + \array_walk($values, function ($val, $i) { + $columnMeta = $this->meta['columns'][$i]; + $columnClass = $columnMeta['class']; + if ($columnClass === false || \in_array($val, [self::VAL_UNDEFINED, null], true)) { + return; + } + $isObject = false; + $type = PhpType::getDebugType($val, Php::ENUM_AS_OBJECT, $isObject); + $this->meta['columns'][$i]['class'] = $isObject && \in_array($columnClass, [$type, null], true) + ? $type + : false; + }); + $this->updateTotals($values); + } + + /** + * Update totals with current row's values + * + * @param array $values Row key=>value array + * + * @return void + */ + private function updateTotals(array $values) + { + $indexes = \array_intersect(\array_keys($values), $this->options['totalCols']); + foreach ($indexes as $i => $key) { + $val = $values[$key]; + if (\is_numeric($val)) { + $this->meta['columns'][$i]['total'] += $val; + } + } + } +} diff --git a/src/Table/OutputHtml.php b/src/Table/OutputHtml.php new file mode 100644 index 00000000..97d80c58 --- /dev/null +++ b/src/Table/OutputHtml.php @@ -0,0 +1,43 @@ +valDumper = $valDumper; + } + + /** + * Output Table as HTML + * + * @param Table $table Table instance + * + * @return string html fragment + */ + public function output(Table $table) + { + if ($this->valDumper) { + TableCell::setValDumper($this->valDumper); + } + return $table->getOuterHtml(); + } +} diff --git a/src/Table/Table.php b/src/Table/Table.php new file mode 100644 index 00000000..c4502f2e --- /dev/null +++ b/src/Table/Table.php @@ -0,0 +1,253 @@ + */ + protected $defaults = array( + 'tagName' => 'table', + ); + + /** @var string */ + protected $tagName = 'table'; + + /** @var Element|null */ + protected $tbody; + + /** @var Element|null */ + protected $tfoot; + + /** @var Element|null */ + protected $thead; + + /** + * Constructor + * + * @param array $children Table (body) rows + */ + public function __construct(array $children = array()) + { + $this->setTbody(); + $this->setChildren($children); + } + + /** + * {@inheritDoc} + */ + public function __serialize() + { + $data = parent::__serialize() + array( + 'caption' => $this->getCaption(), + 'footer' => $this->getFooter(), + 'header' => $this->getHeader(), + 'rows' => $this->getRows(), + ); + \ksort($data); + return \array_filter($data); + } + + /** + * Get Caption + * + * @return Element|null + */ + public function getCaption() + { + return $this->caption; + } + + /** + * {@inheritDoc} + */ + public function getChildren() + { + return \array_filter([ + $this->caption, + $this->thead, + $this->tbody, + $this->tfoot, + ]); + } + + /** + * Get footer row + * + * @return TableRow|list|null + */ + public function getFooter() + { + if ($this->tfoot === null) { + return null; + } + $rows = $this->tfoot->getChildren(); + return \count($rows) > 1 + ? $rows + : $rows[0]; + } + + /** + * Get header row + * + * @return TableRow|list|null + */ + public function getHeader() + { + if ($this->thead === null) { + return null; + } + $rows = $this->thead->getChildren(); + return \count($rows) > 1 + ? $rows + : $rows[0]; + } + + /** + * Get (body)Rows + * + * @return array + */ + public function getRows() + { + return $this->tbody->getChildren(); + } + + /** + * Append row to tbody + * + * @param array|TableRow $row Table row + * + * @return $this + */ + public function appendRow($row) + { + $row = $this->tableRow($row); + $this->tbody->appendChild($row); + return $this; + } + + /** + * {@inheritDoc} + */ + public function setCaption($caption) + { + if (!($caption instanceof Element)) { + $caption = new Element('caption', $caption); + } + $caption->setParent($this); + $caption->setDefaults(array( + 'tagName' => 'caption', + )); + $this->caption = $caption; + return $this; + } + + /** + * {@inheritDoc} + */ + public function setChildren(array $children) + { + $this->caption = null; + $this->thead = null; + $this->setTbody(); + $this->tfoot = null; + \array_walk($children, function ($child) { + $tagName = $child instanceof Element ? $child->getTagname() : null; + if (\in_array($tagName, ['caption', 'thead', 'tbody', 'tfoot'], true)) { + $child->setParent($this); + $this->{$tagName} = $child; + return; + } + $this->appendRow($child); + }); + return $this; + } + + /** + * Set footer row + * + * @param array|TableRow $footer Footer row + * + * @return $this + */ + public function setFooter($footer) + { + $footerRow = $this->tableRow($footer); + $this->tfoot = (new Element('tfoot')) + ->setParent($this) + ->setChildren([$footerRow]); + return $this; + } + + /** + * Set header row + * + * @param array|TableRow $header Header row + * + * @return $this + */ + public function setHeader($header) + { + $headerRow = $this->tableRow($header); + $cells = $headerRow->getCells(); + foreach ($cells as $cell) { + $cell->setTagname('th')->setDefaults(array( + 'attribs' => array('scope' => 'col'), + 'tagName' => 'th', + )); + } + $this->thead = (new Element('thead')) + ->setParent($this) + ->setChildren([$headerRow]); + return $this; + } + + /** + * Set (body) rows + * + * @param array|array $rows Table rows + * + * @return $this + */ + public function setRows(array $rows) + { + $this->tbody->setChildren( + \array_map([$this, 'tableRow'], \array_values($rows)) + ); + return $this; + } + + /** + * Initialize tbody child + * + * @return void + */ + private function setTbody() + { + $this->tbody = new Element('tbody'); + $this->tbody->setParent($this); + } + + /** + * Convert array to TableRow if necessary + * + * @param array|TableRow $row TableRow + * + * @return TableRow + */ + private function tableRow($row) + { + return $row instanceof TableRow + ? $row + : new TableRow($row); + } +} diff --git a/src/Table/TableCell.php b/src/Table/TableCell.php new file mode 100644 index 00000000..dd6f633a --- /dev/null +++ b/src/Table/TableCell.php @@ -0,0 +1,158 @@ + */ + protected $defaults = array( + 'tagName' => 'td', + ); + + /** @var string */ + protected $tagName = 'td'; + + /** @var mixed raw value to be formatted upon rendering */ + protected $value; + + /** @var callable */ + protected static $valDumper = ['bdk\Table\TableCell', 'valDumper']; + + /** + * Constructor + * + * @param mixed $value Cell value + * + * @throws InvalidArgumentException + */ + public function __construct($value = null) + { + $propertyNames = \array_keys(\get_object_vars($this)); + if (\is_array($value) && \array_intersect(\array_keys($value), $propertyNames)) { + foreach ($value as $key => $val) { + $method = 'set' . \ucfirst($key); + $this->$method($val); + } + return; + } + $this->setValue($value); + } + + /** + * {@inheritDoc} + */ + public function __serialize() + { + return parent::__serialize() + array( + 'value' => $this->value, + ); + } + + /** + * {@inheritDoc} + */ + public function getHtml() + { + $html = parent::getHtml(); + if ($html !== null) { + return $html; + } + $this->buildingHtml = true; + $value = \call_user_func(self::$valDumper, $this); + $this->buildingHtml = false; + return $value; + } + + /** + * Get cell value + * + * @return mixed + */ + public function getValue() + { + return $this->value; + } + + /** + * Set value dumper + * + * @param callable $valDumper Callable that accepts TableCell and returns string + * + * @return void + */ + public static function setValDumper(callable $valDumper) + { + self::$valDumper = $valDumper; + } + + /** + * Set cell value + * + * @param mixed $value Cell value + * + * @return $this + */ + public function setValue($value) + { + $this->value = $value; + return $this; + } + + /** + * Default value dumper + * + * @param self $tableCell tableCell to dump + * + * @return string + */ + public static function valDumper(self $tableCell) + { + $value = $tableCell->getValue(); + $type = PhpType::getDebugType($value); + + if (\is_object($value)) { + $type = 'object'; + $value = self::objectToString($value); + } elseif (\in_array($value, [true, false, null], true)) { + $value = \json_encode($value); + $type = $value; + } elseif (\is_string($value) === false && \is_numeric($value) === false) { + // not string or numeric + $value = \print_r($value, true); + } elseif ($value === Factory::VAL_UNDEFINED) { + $type = 'undefined'; + $value = ''; + } + + $class = 't_' . $type; + $tableCell->addClass($class); + + return \htmlspecialchars($value); + } + + /** + * Get string value / representation of object + * + * @param object $obj object to convert to string + * + * @return string + */ + private static function objectToString(object $obj) + { + if (\method_exists($obj, '__toString')) { + return (string) $obj; + } + if ($obj instanceof \DateTime || $obj instanceof \DateTimeImmutable) { + return $obj->format(\DateTime::RFC3339); + } + return \get_class($obj); + } +} diff --git a/src/Table/TableRow.php b/src/Table/TableRow.php new file mode 100644 index 00000000..117dbf88 --- /dev/null +++ b/src/Table/TableRow.php @@ -0,0 +1,75 @@ + */ + protected $defaults = array( + 'tagName' => 'tr', + ); + + /** @var string */ + protected $tagName = 'tr'; + + /** + * Constructor + * + * @param array $children Table cells + */ + public function __construct(array $children = array()) + { + if (ArrayUtil::isList($children) || \reset($children) instanceof Element) { + $this->setChildren($children); + return; + } + foreach ($children as $key => $val) { + $method = 'set' . \ucfirst($key); + $this->$method($val); + } + } + + /** + * Get Cells + * + * @return array + */ + public function getCells() + { + return $this->children; + } + + /** + * Set cells + * + * @param array|array $cells Row cells + * + * @return $this + */ + public function setCells(array $cells) + { + return $this->setChildren($cells); + } + + /** + * {@inheritDoc} + */ + public function setChildren(array $children) + { + $this->children = \array_map(function ($child) { + if (($child instanceof Element) === false) { + $child = new TableCell($child); + } + $child->setParent($this); + return $child; + }, \array_values($children)); + return $this; + } +} diff --git a/tests/Table/ElementTest.php b/tests/Table/ElementTest.php new file mode 100644 index 00000000..1c7db9c9 --- /dev/null +++ b/tests/Table/ElementTest.php @@ -0,0 +1,840 @@ +getTagName()); + self::assertSame([], $element->getChildren()); + self::assertSame('', $element->getHtml()); + } + + /** + * Test constructor with HTML content + */ + public function testConstructorWithHtml() + { + $element = new Element('span', 'Hello World'); + self::assertSame('span', $element->getTagName()); + self::assertSame('Hello World', $element->getHtml()); + } + + /** + * Test constructor with children array + */ + public function testConstructorWithChildren() + { + $child1 = new Element('span', 'Child 1'); + $child2 = new Element('span', 'Child 2'); + $element = new Element('div', [$child1, $child2]); + + self::assertSame('div', $element->getTagName()); + self::assertCount(2, $element->getChildren()); + self::assertSame($element, $child1->getParent()); + self::assertSame($element, $child2->getParent()); + } + + /** + * Test constructor with associative array of properties + */ + public function testConstructorWithProperties() + { + $element = new Element('div', [ + 'attribs' => ['class' => 'test-class', 'id' => 'test-id'], + 'html' => 'Test content', + 'meta' => ['key' => 'value'], + ]); + + self::assertSame('div', $element->getTagName()); + self::assertSame('Test content', $element->getHtml()); + self::assertSame(['class' => ['test-class'], 'id' => 'test-id'], $element->getAttribs()); + self::assertSame('value', $element->getMeta('key')); + } + + /** + * Test setTagName / getTagName + */ + public function testTagName() + { + $element = new Element('div'); + self::assertSame('div', $element->getTagName()); + + $element->setTagName('SPAN'); + self::assertSame('span', $element->getTagName()); + + $element->setTagName('TD'); + self::assertSame('td', $element->getTagName()); + } + + /** + * Test setHtml / getHtml + */ + public function testHtml() + { + $element = new Element('div'); + self::assertSame('', $element->getHtml()); + + $element->setHtml('Bold'); + self::assertSame('Bold', $element->getHtml()); + } + + /** + * Test setText / getText + */ + public function testText() + { + $element = new Element('div'); + $element->setText('Test & "quotes"'); + + self::assertSame('Test & "quotes"', $element->getHtml()); + self::assertSame('Test & "quotes"', $element->getText()); + } + + /** + * Test getText with nested children + */ + public function testGetTextWithChildren() + { + $child1 = new Element('span'); + $child1->setText('Hello '); + + $child2 = new Element('strong'); + $child2->setText('World'); + + $parent = new Element('div', [$child1, $child2]); + + self::assertSame('Hello World', $parent->getText()); + } + + /** + * Test setAttrib / getAttribs + */ + public function testAttribs() + { + $element = new Element('div'); + + $element->setAttrib('id', 'test-id'); + self::assertSame(['id' => 'test-id'], $element->getAttribs()); + + $element->setAttrib('data-value', '123'); + self::assertSame([ + 'data-value' => '123', + 'id' => 'test-id', + ], $element->getAttribs()); + } + + /** + * Test setAttribs (plural) + */ + public function testSetAttribs() + { + $element = new Element('div'); + $element->setAttribs([ + 'id' => 'test-id', + 'class' => 'test-class', + 'data-value' => 'abc', + ]); + + self::assertSame([ + 'class' => ['test-class'], + 'data-value' => 'abc', + 'id' => 'test-id', + ], $element->getAttribs()); + } + + /** + * Test addClass + */ + public function testAddClass() + { + $element = new Element('div'); + + $element->addClass('class1'); + self::assertSame(['class' => ['class1']], $element->getAttribs()); + + $element->addClass('class2'); + self::assertSame(['class' => ['class1', 'class2']], $element->getAttribs()); + + $element->addClass(['class3', 'class4']); + self::assertSame(['class' => ['class1', 'class2', 'class3', 'class4']], $element->getAttribs()); + + // Test duplicate class + $element->addClass('class1'); + self::assertSame(['class' => ['class1', 'class2', 'class3', 'class4']], $element->getAttribs()); + } + + /** + * Test addClass with space-separated string + */ + public function testAddClassWithSpaces() + { + $element = new Element('div'); + $element->addClass('class1 class2 class3'); + + self::assertSame(['class' => ['class1', 'class2', 'class3']], $element->getAttribs()); + } + + /** + * Test removeClass + */ + public function testRemoveClass() + { + $element = new Element('div'); + $element->addClass(['class1', 'class2', 'class3']); + + $element->removeClass('class2'); + self::assertSame(['class' => ['class1', 'class3']], $element->getAttribs()); + + $element->removeClass(['class1', 'class3']); + self::assertSame([], $element->getAttribs()); + } + + /** + * Test removeClass with space-separated string + */ + public function testRemoveClassWithSpaces() + { + $element = new Element('div'); + $element->addClass('class1 class2 class3'); + $element->removeClass('class1 class3'); + + self::assertSame(['class' => ['class2']], $element->getAttribs()); + } + + /** + * Test appendChild + */ + public function testAppendChild() + { + $parent = new Element('div'); + $child1 = new Element('span', 'Child 1'); + $child2 = new Element('span', 'Child 2'); + + $parent->appendChild($child1); + self::assertCount(1, $parent->getChildren()); + self::assertSame($parent, $child1->getParent()); + + $parent->appendChild($child2); + self::assertCount(2, $parent->getChildren()); + self::assertSame($parent, $child2->getParent()); + } + + /** + * Test setChildren + */ + public function testSetChildren() + { + $parent = new Element('div'); + $child1 = new Element('span', 'Child 1'); + $child2 = new Element('span', 'Child 2'); + + $parent->setChildren([$child1, $child2]); + + self::assertCount(2, $parent->getChildren()); + self::assertSame($parent, $child1->getParent()); + self::assertSame($parent, $child2->getParent()); + } + + /** + * Test setChildren with invalid child throws exception + */ + public function testSetChildrenInvalid() + { + $this->expectException('InvalidArgumentException'); + $this->expectExceptionMessage('Children must be instances of'); + + $parent = new Element('div'); + $parent->setChildren(['not an Element object']); + } + + /** + * Test getChildren + */ + public function testGetChildren() + { + $child1 = new Element('span', 'Child 1'); + $child2 = new Element('span', 'Child 2'); + $parent = new Element('div', [$child1, $child2]); + + $children = $parent->getChildren(); + self::assertCount(2, $children); + self::assertSame($child1, $children[0]); + self::assertSame($child2, $children[1]); + } + + /** + * Test getHtml with children + */ + public function testGetHtmlWithChildren() + { + $child1 = new Element('span', 'Child 1'); + $child2 = new Element('strong', 'Child 2'); + $parent = new Element('div', [$child1, $child2]); + + $html = $parent->getHtml(); + self::assertStringContainsString('Child 1', $html); + self::assertStringContainsString('Child 2', $html); + } + + /** + * Test setParent / getParent + */ + public function testParent() + { + $parent = new Element('div'); + $child = new Element('span'); + + self::assertNull($child->getParent()); + + $child->setParent($parent); + self::assertSame($parent, $child->getParent()); + + $child->setParent(null); + self::assertNull($child->getParent()); + } + + /** + * Test setParent with invalid parent throws exception + */ + public function testSetParentInvalid() + { + $this->expectException('InvalidArgumentException'); + $this->expectExceptionMessage('Parent must be instance of'); + + $child = new Element('span'); + $child->setParent('not an Element object'); + } + + /** + * Test getIndex + */ + public function testGetIndex() + { + $parent = new Element('div'); + $child1 = new Element('span', 'Child 1'); + $child2 = new Element('span', 'Child 2'); + $child3 = new Element('span', 'Child 3'); + + $parent->setChildren([$child1, $child2, $child3]); + + self::assertSame(0, $child1->getIndex()); + self::assertSame(1, $child2->getIndex()); + self::assertSame(2, $child3->getIndex()); + } + + /** + * Test getIndex with no parent + */ + public function testGetIndexNoParent() + { + $element = new Element('div'); + self::assertNull($element->getIndex()); + } + + /** + * Test setMeta / getMeta + */ + public function testMeta() + { + $element = new Element('div'); + + $element->setMeta('key1', 'value1'); + self::assertSame('value1', $element->getMeta('key1')); + + $element->setMeta('key2', 'value2'); + self::assertSame('value2', $element->getMeta('key2')); + + // Test getting non-existent key + self::assertNull($element->getMeta('nonexistent')); + self::assertSame('default', $element->getMeta('nonexistent', 'default')); + } + + /** + * Test setMeta with array + */ + public function testSetMetaArray() + { + $element = new Element('div'); + $element->setMeta([ + 'key1' => 'value1', + 'key2' => 'value2', + ]); + + self::assertSame('value1', $element->getMeta('key1')); + self::assertSame('value2', $element->getMeta('key2')); + } + + /** + * Test getMeta with no key returns all meta + */ + public function testGetMetaAll() + { + $element = new Element('div'); + $element->setMeta([ + 'key1' => 'value1', + 'key2' => 'value2', + ]); + + self::assertSame([ + 'key1' => 'value1', + 'key2' => 'value2', + ], $element->getMeta()); + } + + /** + * Test setDefaults + */ + public function testSetDefaults() + { + $element = new Element('div'); + $element->setDefaults([ + 'tagName' => 'div', + 'attribs' => ['class' => ['default-class']], + ]); + + // Defaults should be merged with current attribs + $element->setAttrib('id', 'test-id'); + self::assertSame([ + 'class' => ['default-class'], + 'id' => 'test-id', + ], $element->getAttribs()); + } + + /** + * Test getOuterHtml + */ + public function testGetOuterHtml() + { + $element = new Element('div', 'Hello World'); + $element->setAttrib('id', 'test-id'); + $element->setAttrib('class', 'test-class'); + + $html = $element->getOuterHtml(); + self::assertSame('
Hello World
', $html); + } + + /** + * Test getOuterHtml with children + */ + public function testGetOuterHtmlWithChildren() + { + $child1 = new Element('span', 'Child 1'); + $child2 = new Element('strong', 'Child 2'); + $parent = new Element('div', [$child1, $child2]); + $parent->setAttrib('id', 'parent'); + + $html = $parent->getOuterHtml(); + + self::assertStringContainsString('
', $html); + self::assertStringContainsString('Child 1', $html); + self::assertStringContainsString('Child 2', $html); + self::assertStringContainsString('
', $html); + } + + /** + * Test __serialize + */ + public function testSerialize() + { + $element = new Element('span', 'Test content'); + $element->setAttrib('id', 'test-id'); + $element->setMeta('key', 'value'); + + $data = $element->__serialize(); + + // tagName should be in data since it's different from default 'div' + self::assertArrayHasKey('tagName', $data); + self::assertArrayHasKey('html', $data); + self::assertArrayHasKey('attribs', $data); + self::assertArrayHasKey('meta', $data); + self::assertSame('span', $data['tagName']); + self::assertSame('Test content', $data['html']); + self::assertSame(['id' => 'test-id'], $data['attribs']); + self::assertSame(['key' => 'value'], $data['meta']); + } + + /** + * Test __serialize with children + */ + public function testSerializeWithChildren() + { + $child1 = new Element('span', 'Child 1'); + $child2 = new Element('strong', 'Child 2'); + $parent = new Element('div', [$child1, $child2]); + + $data = $parent->__serialize(); + + self::assertArrayHasKey('children', $data); + self::assertCount(2, $data['children']); + } + + /** + * Test __unserialize + */ + public function testUnserialize() + { + $element = new Element('span'); + $element->__unserialize([ + 'tagName' => 'div', + 'html' => 'Test content', + 'attribs' => ['id' => 'test-id'], + 'meta' => ['key' => 'value'], + ]); + + self::assertSame('div', $element->getTagName()); + self::assertSame('Test content', $element->getHtml()); + self::assertSame(['id' => 'test-id'], $element->getAttribs()); + self::assertSame('value', $element->getMeta('key')); + } + + /** + * Test serialize / unserialize methods (Serializable interface) + */ + public function testSerializableMethods() + { + $element = new Element('span', 'Test content'); + $element->setAttrib('id', 'test-id'); + $element->setMeta('key', 'value'); + + $serialized = $element->serialize(); + self::assertIsString($serialized); + + $newElement = new Element('div'); + $newElement->unserialize($serialized); + + self::assertSame('span', $newElement->getTagName()); + self::assertSame('Test content', $newElement->getHtml()); + self::assertSame(['id' => 'test-id'], $newElement->getAttribs()); + self::assertSame('value', $newElement->getMeta('key')); + } + + /** + * Test jsonSerialize + */ + public function testJsonSerialize() + { + $element = new Element('span', 'Test content'); + $element->setAttrib('id', 'test-id'); + + $json = $element->jsonSerialize(); + + self::assertIsArray($json); + self::assertArrayHasKey('tagName', $json); + self::assertArrayHasKey('html', $json); + self::assertArrayHasKey('attribs', $json); + } + + /** + * Test jsonSerialize with only HTML returns string + */ + public function testJsonSerializeHtmlOnly() + { + $element = new Element('div', 'Test content'); + + $json = $element->jsonSerialize(); + + self::assertSame('Test content', $json); + } + + /** + * Test jsonSerialize with only children returns array + */ + public function testJsonSerializeChildrenOnly() + { + $child1 = new Element('span', 'Child 1'); + $child2 = new Element('strong', 'Child 2'); + $parent = new Element('div', [$child1, $child2]); + + $json = $parent->jsonSerialize(); + + self::assertIsArray($json); + self::assertCount(2, $json); + } + + /** + * Test that empty values are not serialized + */ + public function testSerializeEmptyValues() + { + $element = new Element('span'); + + $data = $element->__serialize(); + + // Empty arrays and nulls should not be included, but tagName differs from default + self::assertArrayHasKey('tagName', $data); + self::assertSame('span', $data['tagName']); + // html is empty string which gets filtered out + self::assertArrayNotHasKey('attribs', $data); + self::assertArrayNotHasKey('meta', $data); + self::assertArrayNotHasKey('children', $data); + } + + /** + * Test that default values are not serialized + */ + public function testSerializeDefaultValues() + { + $element = new Element('div'); + $element->setDefaults([ + 'tagName' => 'div', + 'attribs' => ['class' => ['default-class']], + ]); + + $data = $element->__serialize(); + + // Default tagName should not be in serialized data + self::assertArrayNotHasKey('tagName', $data); + } + + /** + * Test fluent interface (chaining) + */ + public function testFluentInterface() + { + $element = new Element('div'); + + $result = $element + ->setTagName('span') + ->setHtml('Test') + ->setAttrib('id', 'test-id') + ->addClass('test-class') + ->setMeta('key', 'value'); + + self::assertSame($element, $result); + self::assertSame('span', $element->getTagName()); + self::assertSame('Test', $element->getHtml()); + self::assertSame([ + 'class' => ['test-class'], + 'id' => 'test-id', + ], $element->getAttribs()); + self::assertSame('value', $element->getMeta('key')); + } + + /** + * Test complex nested structure + */ + public function testComplexNestedStructure() + { + $grandchild1 = new Element('em', 'Emphasized'); + $grandchild2 = new Element('strong', 'Bold'); + + $child1 = new Element('span', [$grandchild1]); + $child1->setAttrib('class', 'child-span'); + + $child2 = new Element('div', [$grandchild2]); + $child2->setAttrib('id', 'child-div'); + + $parent = new Element('article', [$child1, $child2]); + $parent->setAttrib('class', 'parent-article'); + + // Test structure + self::assertSame($parent, $child1->getParent()); + self::assertSame($parent, $child2->getParent()); + self::assertSame($child1, $grandchild1->getParent()); + self::assertSame($child2, $grandchild2->getParent()); + + // Test HTML generation + $html = $parent->getOuterHtml(); + self::assertStringContainsString('
', $html); + self::assertStringContainsString('', $html); + self::assertStringContainsString('
', $html); + self::assertStringContainsString('Emphasized', $html); + self::assertStringContainsString('Bold', $html); + } + + /** + * Test setting and getting empty class attribute + */ + public function testEmptyClassAttribute() + { + $element = new Element('div'); + $element->setAttrib('class', []); + + // Empty class should not be in attributes + self::assertSame([], $element->getAttribs()); + } + + /** + * Test class normalization with duplicates and empty strings + */ + public function testClassNormalization() + { + $element = new Element('div'); + $element->addClass(['class1', '', 'class2', 'class1', 'class3', '']); + + // Should filter out empty strings and duplicates, and sort + self::assertSame(['class' => ['class1', 'class2', 'class3']], $element->getAttribs()); + } + + /** + * Test unserialize with invalid data + */ + public function testUnserializeWithInvalidData() + { + $element = new Element('div', 'Original'); + + // Unserialize with non-array should not change element + $element->unserialize(\serialize('not an array')); + + self::assertSame('div', $element->getTagName()); + self::assertSame('Original', $element->getHtml()); + } + + /** + * Test constructor with empty associative array + */ + public function testConstructorWithEmptyAssociativeArray() + { + $element = new Element('div', []); + + self::assertSame('div', $element->getTagName()); + self::assertSame([], $element->getChildren()); + } + + /** + * Test getAttribs with defaults + */ + public function testGetAttribsWithDefaults() + { + $element = new Element('div'); + $element->setDefaults([ + 'attribs' => [ + 'class' => ['default-class'], + 'data-default' => 'value', + ], + ]); + + $element->setAttrib('id', 'test-id'); + $element->addClass('custom-class'); + + $attribs = $element->getAttribs(); + + // Defaults should be merged with custom attributes + self::assertArrayHasKey('class', $attribs); + self::assertContains('default-class', $attribs['class']); + self::assertContains('custom-class', $attribs['class']); + self::assertSame('value', $attribs['data-default']); + self::assertSame('test-id', $attribs['id']); + } + + /** + * Test getText with HTML entities + */ + public function testGetTextWithHtmlEntities() + { + $element = new Element('div'); + $element->setHtml('Test <tag> & "quotes"'); + + // getText should decode HTML entities + self::assertSame('Test & "quotes"', $element->getText()); + } + + /** + * Test getText with nested HTML tags + */ + public function testGetTextWithNestedTags() + { + $element = new Element('div'); + $element->setHtml('Bold and italic text'); + + // getText should strip all tags + self::assertSame('Bold and italic text', $element->getText()); + } + + /** + * Test serialization of nested children + */ + public function testSerializeNestedChildren() + { + $grandchild = new Element('em', 'Nested'); + $child = new Element('span', [$grandchild]); + $parent = new Element('div', [$child]); + + $data = $parent->__serialize(); + + self::assertArrayHasKey('children', $data); + self::assertIsArray($data['children']); + self::assertCount(1, $data['children']); + } + + /** + * Test that addClass returns the element for chaining + */ + public function testAddClassReturnsElement() + { + $element = new Element('div'); + $result = $element->addClass('test-class'); + + self::assertSame($element, $result); + } + + /** + * Test that removeClass returns the element for chaining + */ + public function testRemoveClassReturnsElement() + { + $element = new Element('div'); + $element->addClass('test-class'); + $result = $element->removeClass('test-class'); + + self::assertSame($element, $result); + } + + /** + * Test that appendChild returns the element for chaining + */ + public function testAppendChildReturnsElement() + { + $parent = new Element('div'); + $child = new Element('span'); + $result = $parent->appendChild($child); + + self::assertSame($parent, $result); + } + + /** + * Test empty HTML string + */ + public function testEmptyHtmlString() + { + $element = new Element('div', ''); + + self::assertSame('', $element->getHtml()); + self::assertSame('', $element->getText()); + } + + /** + * Test getHtml with both children and HTML set + */ + public function testGetHtmlPrioritizesChildren() + { + $child = new Element('span', 'Child'); + $parent = new Element('div', [$child]); + + // Setting HTML doesn't affect children + $parent->setHtml('New HTML content'); + + // Children still exist + self::assertCount(1, $parent->getChildren()); + + // getHtml returns children HTML when children exist, not the set HTML + $html = $parent->getHtml(); + self::assertStringContainsString('Child', $html); + } +} diff --git a/tests/Table/FactoryTest.php b/tests/Table/FactoryTest.php new file mode 100644 index 00000000..557da3a5 --- /dev/null +++ b/tests/Table/FactoryTest.php @@ -0,0 +1,750 @@ + 'John', 'age' => 30], + ['name' => 'Jane', 'age' => 25], + ]; + + $table = $factory->create($data); + + self::assertInstanceOf(self::CLASS_TABLE, $table); + self::assertCount(2, $table->getRows()); + } + + /** + * Test create with empty array + */ + public function testCreateWithEmptyArray() + { + $factory = new Factory(); + $table = $factory->create([]); + + self::assertInstanceOf(self::CLASS_TABLE, $table); + self::assertCount(0, $table->getRows()); + } + + /** + * Test create with indexed array + */ + public function testCreateWithIndexedArray() + { + $factory = new Factory(); + $data = [ + ['Apple', 'Red'], + ['Banana', 'Yellow'], + ['Orange', 'Orange'], + ]; + + $table = $factory->create($data); + + self::assertCount(3, $table->getRows()); + } + + /** + * Test create with objects + */ + public function testCreateWithObjects() + { + $factory = new Factory(); + + $obj1 = new stdClass(); + $obj1->name = 'Item 1'; + $obj1->price = 10.50; + + $obj2 = new stdClass(); + $obj2->name = 'Item 2'; + $obj2->price = 20.75; + + $data = [$obj1, $obj2]; + + $table = $factory->create($data); + + self::assertInstanceOf(self::CLASS_TABLE, $table); + self::assertCount(2, $table->getRows()); + } + + /** + * Test create with object as data source + */ + public function testCreateWithObjectAsData() + { + $factory = new Factory(); + + $data = new stdClass(); + $data->item1 = ['name' => 'Item 1', 'value' => 100]; + $data->item2 = ['name' => 'Item 2', 'value' => 200]; + + $table = $factory->create($data); + + self::assertInstanceOf(self::CLASS_TABLE, $table); + self::assertCount(2, $table->getRows()); + } + + /** + * Test create with scalar values + */ + public function testCreateWithScalarValues() + { + $factory = new Factory(); + $data = [10, true, false, null, 'string']; + + $table = $factory->create($data); + + self::assertInstanceOf(self::CLASS_TABLE, $table); + self::assertCount(5, $table->getRows()); + } + + /** + * Test create with mixed array structures + */ + public function testCreateWithMixedArrays() + { + $factory = new Factory(); + $data = [ + ['a' => 1, 'b' => 2], + ['a' => 3, 'c' => 4], + ['b' => 5, 'c' => 6], + ]; + + $table = $factory->create($data); + + self::assertCount(3, $table->getRows()); + // Should have columns for index, a, b, c + $header = $table->getHeader(); + self::assertInstanceOf(self::CLASS_TABLE_ROW, $header); + } + + /** + * Test column ordering with inconsistent keys + */ + public function testColumnOrderingWithInconsistentKeys() + { + $factory = new Factory(); + $data = [ + ['name' => 'John', 'age' => 30, 'city' => 'NYC'], + ['name' => 'Jane', 'city' => 'LA', 'state' => 'CA'], + ['age' => 25, 'name' => 'Bob', 'state' => 'TX'], + ]; + + $table = $factory->create($data); + + self::assertCount(3, $table->getRows()); + self::assertInstanceOf(self::CLASS_TABLE, $table); + } + + /** + * Test with column labels option + */ + public function testCreateWithColumnLabels() + { + $factory = new Factory(); + $data = [ + ['name' => 'John', 'age' => 30], + ['name' => 'Jane', 'age' => 25], + ]; + + $options = [ + 'columnLabels' => [ + 'age' => 'Age (years)', + 'name' => 'Full Name', + ], + ]; + + $table = $factory->create($data, $options); + + self::assertInstanceOf(self::CLASS_TABLE, $table); + $header = $table->getHeader(); + self::assertInstanceOf(self::CLASS_TABLE_ROW, $header); + } + + /** + * Test with columns option (specify columns) + */ + public function testCreateWithColumnsOption() + { + $factory = new Factory(); + $data = [ + ['name' => 'John', 'age' => 30, 'city' => 'NYC'], + ['name' => 'Jane', 'age' => 25, 'city' => 'LA'], + ]; + + $options = [ + 'columns' => ['name', 'age'], + ]; + + $table = $factory->create($data, $options); + + self::assertInstanceOf(self::CLASS_TABLE, $table); + // Should only have index, name, and age columns + $header = $table->getHeader(); + self::assertInstanceOf(self::CLASS_TABLE_ROW, $header); + $cells = $header->getCells(); + self::assertCount(3, $cells); // index, name, age + } + + /** + * Test with totalCols option + */ + public function testCreateWithTotalCols() + { + $factory = new Factory(); + $data = [ + ['product' => 'A', 'quantity' => 10, 'price' => 5.50], + ['product' => 'B', 'quantity' => 20, 'price' => 3.25], + ['product' => 'C', 'quantity' => 15, 'price' => 7.00], + ]; + + $options = [ + 'totalCols' => ['quantity', 'price'], + ]; + + $table = $factory->create($data, $options); + + self::assertInstanceOf(self::CLASS_TABLE, $table); + + // Should have footer with totals + $footer = $table->getFooter(); + self::assertInstanceOf(self::CLASS_TABLE_ROW, $footer); + } + + /** + * Test constructor with default options + */ + public function testConstructorWithDefaultOptions() + { + $defaultOptions = [ + 'columnLabels' => [ + 'id' => 'ID', + 'name' => 'Name', + ], + ]; + + $factory = new Factory($defaultOptions); + + $data = [ + ['id' => 1, 'name' => 'Item 1'], + ['id' => 2, 'name' => 'Item 2'], + ]; + + $table = $factory->create($data); + + self::assertInstanceOf(self::CLASS_TABLE, $table); + } + + /** + * Test that options are merged correctly + */ + public function testOptionsAreMerged() + { + $defaultOptions = [ + 'columnLabels' => [ + 'id' => 'ID', + ], + ]; + + $factory = new Factory($defaultOptions); + + $createOptions = [ + 'columnLabels' => [ + 'name' => 'Name', + ], + ]; + + $data = [ + ['id' => 1, 'name' => 'Item'], + ]; + + $table = $factory->create($data, $createOptions); + + self::assertInstanceOf(self::CLASS_TABLE, $table); + } + + /** + * Test header is created + */ + public function testHeaderIsCreated() + { + $factory = new Factory(); + $data = [ + ['col1' => 'A', 'col2' => 'B'], + ]; + + $table = $factory->create($data); + + $header = $table->getHeader(); + self::assertInstanceOf(self::CLASS_TABLE_ROW, $header); + + $cells = $header->getCells(); + self::assertGreaterThan(0, \count($cells)); + } + + /** + * Test footer is created with totals + */ + public function testFooterIsCreatedWithTotals() + { + $factory = new Factory(); + $data = [ + ['value' => 10], + ['value' => 20], + ['value' => 30], + ]; + + $table = $factory->create($data, ['totalCols' => ['value']]); + + $footer = $table->getFooter(); + self::assertInstanceOf(self::CLASS_TABLE_ROW, $footer); + } + + /** + * Test no footer when totalCols is empty + */ + public function testNoFooterWhenTotalColsEmpty() + { + $factory = new Factory(); + $data = [ + ['value' => 10], + ]; + + $table = $factory->create($data); + + self::assertNull($table->getFooter()); + } + + /** + * Test meta information is set + */ + public function testMetaInformationIsSet() + { + $factory = new Factory(); + $data = [ + ['name' => 'Item'], + ]; + + $table = $factory->create($data); + + $meta = $table->getMeta(); + self::assertIsArray($meta); + self::assertArrayHasKey('columns', $meta); + } + + /** + * Test meta class is set when data is object + */ + public function testMetaClassIsSetWhenDataIsObject() + { + $factory = new Factory(); + + $data = new stdClass(); + $data->item1 = ['value' => 1]; + + $table = $factory->create($data); + + $meta = $table->getMeta(); + self::assertSame('stdClass', $meta['class']); + } + + /** + * Test KEY_INDEX column has proper cell attributes + */ + public function testKeyIndexColumnHasProperAttributes() + { + $factory = new Factory(); + $data = [ + ['value' => 1], + ]; + + $table = $factory->create($data); + + $rows = $table->getRows(); + $firstRow = $rows[0]; + $cells = $firstRow->getCells(); + $firstCell = $cells[0]; + + // First cell should be the index column + self::assertSame('th', $firstCell->getTagName()); + self::assertContains('t_key', $firstCell->getAttribs()['class']); + self::assertSame('row', $firstCell->getAttribs()['scope']); + } + + /** + * Test VAL_UNDEFINED constant + */ + public function testValUndefinedConstant() + { + self::assertIsString(Factory::VAL_UNDEFINED); + self::assertNotEmpty(Factory::VAL_UNDEFINED); + } + + /** + * Test KEY_CLASS_NAME constant + */ + public function testKeyClassNameConstant() + { + self::assertSame('___class_name', Factory::KEY_CLASS_NAME); + } + + /** + * Test KEY_INDEX constant + */ + public function testKeyIndexConstant() + { + self::assertIsString(Factory::KEY_INDEX); + } + + /** + * Test KEY_SCALAR constant + */ + public function testKeyScalarConstant() + { + self::assertIsString(Factory::KEY_SCALAR); + } + + /** + * Test with null values + */ + public function testCreateWithNullValues() + { + $factory = new Factory(); + $data = [ + ['name' => 'John', 'age' => null], + ['name' => null, 'age' => 25], + ]; + + $table = $factory->create($data); + + self::assertInstanceOf(self::CLASS_TABLE, $table); + self::assertCount(2, $table->getRows()); + } + + /** + * Test with boolean values + */ + public function testCreateWithBooleanValues() + { + $factory = new Factory(); + $data = [ + ['active' => true, 'visible' => false], + ['active' => false, 'visible' => true], + ]; + + $table = $factory->create($data); + + self::assertInstanceOf(self::CLASS_TABLE, $table); + self::assertCount(2, $table->getRows()); + } + + /** + * Test with numeric keys + */ + public function testCreateWithNumericKeys() + { + $factory = new Factory(); + $data = [ + [10 => 'A', 20 => 'B'], + [10 => 'C', 20 => 'D'], + ]; + + $table = $factory->create($data); + + self::assertInstanceOf(self::CLASS_TABLE, $table); + } + + /** + * Test column meta with objects of same class + */ + public function testColumnMetaWithObjectsOfSameClass() + { + $factory = new Factory(); + + $obj1 = new stdClass(); + $obj1->value = 1; + + $obj2 = new stdClass(); + $obj2->value = 2; + + $data = [ + ['object' => $obj1], + ['object' => $obj2], + ]; + + $table = $factory->create($data); + + $meta = $table->getMeta(); + // Column meta should track that all values are stdClass + self::assertIsArray($meta['columns']); + } + + /** + * Test with nested arrays + */ + public function testCreateWithNestedArrays() + { + $factory = new Factory(); + $data = [ + ['name' => 'Item', 'details' => ['color' => 'red', 'size' => 'large']], + ]; + + $table = $factory->create($data); + + self::assertInstanceOf(self::CLASS_TABLE, $table); + } + + /** + * Test totals calculation + */ + public function testTotalsCalculation() + { + $factory = new Factory(); + $data = [ + ['qty' => 10, 'price' => 5.50], + ['qty' => 20, 'price' => 3.25], + ['qty' => 15, 'price' => 7.00], + ]; + + $table = $factory->create($data, ['totalCols' => ['qty', 'price']]); + + $footer = $table->getFooter(); + self::assertInstanceOf(self::CLASS_TABLE_ROW, $footer); + + $cells = $footer->getCells(); + // Check that totals were calculated (45 for qty, 15.75 for price) + self::assertGreaterThan(0, \count($cells)); + } + + /** + * Test totals ignore non-numeric values + */ + public function testTotalsIgnoreNonNumericValues() + { + $factory = new Factory(); + $data = [ + ['value' => 10], + ['value' => 'text'], + ['value' => 20], + ]; + + $table = $factory->create($data, ['totalCols' => ['value']]); + + $footer = $table->getFooter(); + self::assertInstanceOf(self::CLASS_TABLE_ROW, $footer); + } + + /** + * Test empty string values + */ + public function testCreateWithEmptyStrings() + { + $factory = new Factory(); + $data = [ + ['name' => '', 'value' => 0], + ]; + + $table = $factory->create($data); + + self::assertInstanceOf(self::CLASS_TABLE, $table); + } + + /** + * Test with single row + */ + public function testCreateWithSingleRow() + { + $factory = new Factory(); + $data = [ + ['only' => 'row'], + ]; + + $table = $factory->create($data); + + self::assertCount(1, $table->getRows()); + } + + /** + * Test with many rows + */ + public function testCreateWithManyRows() + { + $factory = new Factory(); + $data = []; + + for ($i = 0; $i < 100; $i++) { + $data[] = ['index' => $i, 'value' => $i * 2]; + } + + $table = $factory->create($data); + + self::assertCount(100, $table->getRows()); + } + + /** + * Test multiple create calls with same factory + */ + public function testMultipleCreateCalls() + { + $factory = new Factory(); + + $data1 = [['a' => 1]]; + $table1 = $factory->create($data1); + + $data2 = [['b' => 2]]; + $table2 = $factory->create($data2); + + self::assertInstanceOf(self::CLASS_TABLE, $table1); + self::assertInstanceOf(self::CLASS_TABLE, $table2); + self::assertNotSame($table1, $table2); + } + + /** + * Test with object having properties + */ + public function testCreateWithObjectHavingProperties() + { + $factory = new Factory(); + + $obj = new class { + public $publicProp = 'public'; + protected $protectedProp = 'protected'; + private $privateProp = 'private'; + }; + + $data = [$obj]; + + $table = $factory->create($data); + + self::assertInstanceOf(self::CLASS_TABLE, $table); + } + + /** + * Test with DateTime objects + */ + public function testCreateWithDateTimeObjects() + { + $factory = new Factory(); + $data = [ + ['date' => new \DateTime('2025-01-01')], + ['date' => new \DateTime('2025-12-31')], + ]; + + $table = $factory->create($data); + + self::assertInstanceOf(self::CLASS_TABLE, $table); + } + + /** + * Test column labels for special keys + */ + public function testColumnLabelsForSpecialKeys() + { + $factory = new Factory([ + 'columnLabels' => [ + Factory::KEY_INDEX => 'Index', + Factory::KEY_SCALAR => 'Value', + ], + ]); + + $data = [1, 2, 3]; + + $table = $factory->create($data); + + self::assertInstanceOf(self::CLASS_TABLE, $table); + } + + /** + * Test with associative array having string keys + */ + public function testCreateWithAssociativeArrayStringKeys() + { + $factory = new Factory(); + $data = [ + 'first' => ['name' => 'First'], + 'second' => ['name' => 'Second'], + ]; + + $table = $factory->create($data); + + self::assertCount(2, $table->getRows()); + } + + /** + * Test that undefined values are handled + */ + public function testUndefinedValuesAreHandled() + { + $factory = new Factory(); + $data = [ + ['a' => 1, 'b' => 2], + ['a' => 3], // 'b' is undefined + ]; + + $table = $factory->create($data); + + self::assertCount(2, $table->getRows()); + } + + /** + * Test with object missing specified columns + */ + public function testWithObjectMissingSpecifiedColumns() + { + $factory = new Factory(); + + $obj = new stdClass(); + $obj->existing = 'value'; + + $data = [$obj]; + $options = ['columns' => ['existing', 'nonexistent']]; + + $table = $factory->create($data, $options); + + self::assertInstanceOf(self::CLASS_TABLE, $table); + } + + /** + * Test column consistency across rows + */ + public function testColumnConsistencyAcrossRows() + { + $factory = new Factory(); + $data = [ + ['a' => 1], + ['b' => 2], + ['c' => 3], + ]; + + $table = $factory->create($data); + + // Each row should have same number of cells (including undefined values) + $rows = $table->getRows(); + $cellCount = \count($rows[0]->getCells()); + + foreach ($rows as $row) { + self::assertCount($cellCount, $row->getCells()); + } + } +} diff --git a/tests/Table/TableCellTest.php b/tests/Table/TableCellTest.php new file mode 100644 index 00000000..40caf9cf --- /dev/null +++ b/tests/Table/TableCellTest.php @@ -0,0 +1,727 @@ +getProperty('valDumper'); + $property->setAccessible(true); + self::$originalValDumper = $property->getValue(); + } + + /** + * Tear down after each test + */ + public function tearDown(): void + { + parent::tearDown(); + // Restore original dumper + TableCell::setValDumper(self::$originalValDumper); + } + + /** + * Test basic constructor + */ + public function testConstructorBasic() + { + $cell = new TableCell(); + + self::assertSame('td', $cell->getTagName()); + self::assertNull($cell->getValue()); + } + + /** + * Test constructor with string value + */ + public function testConstructorWithString() + { + $cell = new TableCell('Test Value'); + + self::assertSame('Test Value', $cell->getValue()); + self::assertSame('td', $cell->getTagName()); + } + + /** + * Test constructor with integer value + */ + public function testConstructorWithInteger() + { + $cell = new TableCell(42); + + self::assertSame(42, $cell->getValue()); + } + + /** + * Test constructor with float value + */ + public function testConstructorWithFloat() + { + $cell = new TableCell(3.14); + + self::assertSame(3.14, $cell->getValue()); + } + + /** + * Test constructor with boolean value + */ + public function testConstructorWithBoolean() + { + $cell = new TableCell(true); + + self::assertTrue($cell->getValue()); + } + + /** + * Test constructor with null value + */ + public function testConstructorWithNull() + { + $cell = new TableCell(null); + + self::assertNull($cell->getValue()); + } + + /** + * Test constructor with array value + */ + public function testConstructorWithArrayValue() + { + $cell = new TableCell(['a', 'b', 'c']); + + self::assertSame(['a', 'b', 'c'], $cell->getValue()); + } + + /** + * Test constructor with associative array (properties) + */ + public function testConstructorWithProperties() + { + $cell = new TableCell([ + 'attribs' => ['class' => 'highlight'], + 'tagName' => 'th', + 'value' => 'Test', + ]); + + self::assertSame('Test', $cell->getValue()); + self::assertSame(['class' => ['highlight']], $cell->getAttribs()); + self::assertSame('th', $cell->getTagName()); + } + + /** + * Test getValue + */ + public function testGetValue() + { + $cell = new TableCell('My Value'); + + self::assertSame('My Value', $cell->getValue()); + } + + /** + * Test setValue + */ + public function testSetValue() + { + $cell = new TableCell(); + + $result = $cell->setValue('New Value'); + + self::assertSame($cell, $result); + self::assertSame('New Value', $cell->getValue()); + } + + /** + * Test setValue with different types + */ + public function testSetValueDifferentTypes() + { + $cell = new TableCell(); + + $cell->setValue(100); + self::assertSame(100, $cell->getValue()); + + $cell->setValue(true); + self::assertTrue($cell->getValue()); + + $cell->setValue(['array']); + self::assertSame(['array'], $cell->getValue()); + } + + /** + * Test getHtml with explicit HTML set + */ + public function testGetHtmlWithExplicitHtml() + { + $cell = new TableCell('value'); + $cell->setHtml('Bold'); + + $html = $cell->getHtml(); + + self::assertSame('Bold', $html); + } + + /** + * Test getHtml with value dumper (default) + */ + public function testGetHtmlWithValueDumper() + { + $cell = new TableCell('Test String'); + + $html = $cell->getHtml(); + + self::assertStringContainsString('Test String', $html); + // Default dumper adds class + self::assertArrayHasKey('class', $cell->getAttribs()); + } + + /** + * Test getHtml with string value + */ + public function testGetHtmlStringValue() + { + $cell = new TableCell('Hello World'); + + $html = $cell->getHtml(); + + self::assertSame('Hello World', $html); + self::assertContains('t_string', $cell->getAttribs()['class']); + } + + /** + * Test getHtml with integer value + */ + public function testGetHtmlIntegerValue() + { + $cell = new TableCell(123); + + $html = $cell->getHtml(); + + self::assertSame('123', $html); + self::assertContains('t_int', $cell->getAttribs()['class']); + } + + /** + * Test getHtml with float value + */ + public function testGetHtmlFloatValue() + { + $cell = new TableCell(45.67); + + $html = $cell->getHtml(); + + self::assertSame('45.67', $html); + self::assertContains('t_float', $cell->getAttribs()['class']); + } + + /** + * Test getHtml with true value + */ + public function testGetHtmlTrueValue() + { + $cell = new TableCell(true); + + $html = $cell->getHtml(); + + self::assertSame('true', $html); + self::assertContains('t_true', $cell->getAttribs()['class']); + } + + /** + * Test getHtml with false value + */ + public function testGetHtmlFalseValue() + { + $cell = new TableCell(false); + + $html = $cell->getHtml(); + + self::assertSame('false', $html); + self::assertContains('t_false', $cell->getAttribs()['class']); + } + + /** + * Test getHtml with null value + */ + public function testGetHtmlNullValue() + { + $cell = new TableCell(null); + + $html = $cell->getHtml(); + + self::assertSame('null', $html); + self::assertContains('t_null', $cell->getAttribs()['class']); + } + + /** + * Test getHtml with array value + */ + public function testGetHtmlArrayValue() + { + $cell = new TableCell(['a', 'b', 'c']); + + $html = $cell->getHtml(); + + self::assertStringContainsString('Array', $html); + self::assertContains('t_array', $cell->getAttribs()['class']); + } + + /** + * Test getHtml with object having __toString + */ + public function testGetHtmlObjectWithToString() + { + $obj = new class { + public function __toString() + { + return 'String Representation'; + } + }; + + $cell = new TableCell($obj); + $html = $cell->getHtml(); + + self::assertSame('String Representation', $html); + self::assertContains('t_object', $cell->getAttribs()['class']); + } + + /** + * Test getHtml with DateTime object + */ + public function testGetHtmlDateTimeObject() + { + $date = new DateTime('2025-12-29 10:30:00'); + + $cell = new TableCell($date); + $html = $cell->getHtml(); + + self::assertStringContainsString('2025-12-29', $html); + self::assertStringContainsString('10:30:00', $html); + self::assertContains('t_object', $cell->getAttribs()['class']); + } + + /** + * Test getHtml with DateTimeImmutable object + */ + public function testGetHtmlDateTimeImmutableObject() + { + $date = new DateTimeImmutable('2025-01-15 14:45:30'); + + $cell = new TableCell($date); + $html = $cell->getHtml(); + + self::assertStringContainsString('2025-01-15', $html); + self::assertStringContainsString('14:45:30', $html); + self::assertContains('t_object', $cell->getAttribs()['class']); + } + + /** + * Test getHtml with object without __toString + */ + public function testGetHtmlObjectWithoutToString() + { + $obj = new \stdClass(); + + $cell = new TableCell($obj); + $html = $cell->getHtml(); + + self::assertSame('stdClass', $html); + self::assertContains('t_object', $cell->getAttribs()['class']); + } + + /** + * Test getHtml with undefined value + */ + public function testGetHtmlUndefinedValue() + { + $cell = new TableCell(Factory::VAL_UNDEFINED); + + $html = $cell->getHtml(); + + self::assertSame('', $html); + self::assertContains('t_undefined', $cell->getAttribs()['class']); + } + + /** + * Test getHtml with special characters + */ + public function testGetHtmlSpecialCharacters() + { + $cell = new TableCell(''); + + $html = $cell->getHtml(); + + self::assertStringContainsString('<script>', $html); + self::assertStringContainsString('</script>', $html); + self::assertStringNotContainsString(' doesn't appear inside our ', - \json_encode(Type::TYPE_FLOAT_INF), - \json_encode(Type::TYPE_FLOAT_NAN), - \json_encode(Abstracter::UNDEFINED), - ], - [ - '<\\/script>', - 'Infinity', - 'NaN', - 'undefined', - ], - $str - ); - return $str; + return \strtr($str, array( + '' => '<\\/script>', + \json_encode(Type::TYPE_FLOAT_INF) => 'Infinity', + \json_encode(Type::TYPE_FLOAT_NAN) => 'NaN', + \json_encode(Abstracter::UNDEFINED) => 'undefined', + )); } /** diff --git a/src/Debug/Route/Text.php b/src/Debug/Route/Text.php index 729d8901..e4e29032 100644 --- a/src/Debug/Route/Text.php +++ b/src/Debug/Route/Text.php @@ -25,8 +25,6 @@ class Text extends AbstractRoute public function __construct(Debug $debug) { parent::__construct($debug); - if (!$this->dumper) { - $this->dumper = $debug->getDump('text'); - } + $this->dumper = $debug->getDump('text'); } } diff --git a/src/Debug/Route/WampCrate.php b/src/Debug/Route/WampCrate.php index 061fa2d5..3f039ed9 100644 --- a/src/Debug/Route/WampCrate.php +++ b/src/Debug/Route/WampCrate.php @@ -204,6 +204,7 @@ private function getErrorTraceMeta(LogEntry $logEntry) $meta ); $this->debug->rootInstance->getPlugin('methodTrace')->doTrace($logEntryTmp); + $logEntryTmp->crate(); return \array_replace_recursive( $logEntryTmp['meta'], array( diff --git a/src/Debug/Utility/PhpType.php b/src/Debug/Utility/PhpType.php index ccb6b625..4170832b 100644 --- a/src/Debug/Utility/PhpType.php +++ b/src/Debug/Utility/PhpType.php @@ -11,6 +11,7 @@ namespace bdk\Debug\Utility; use bdk\Debug\Utility\Php; +use Closure; use Exception; use InvalidArgumentException; use UnitEnum; @@ -108,7 +109,7 @@ public static function getDebugType($val, $opts = 0, &$isObject = false) * @param int $opts Bitmask of ENUM_AS_OBJECT flag * @param bool $isObject Whether the value is an object * Closure: false - * UnitEnum: true if Php::ENUM_AS_OBJECT flag passed + * UnitEnum: true/false depending on Php::ENUM_AS_OBJECT flag passed * other objects: true * * @return string @@ -120,7 +121,7 @@ public static function getDebugTypeObject($obj, $opts = 0, &$isObject = false) if ($obj instanceof UnitEnum && !$enumAsObject) { return \get_class($obj) . '::' . $obj->name; } - $isObject = $obj instanceof \Closure === false; + $isObject = $obj instanceof Closure === false; $class = \is_object($obj) ? \get_class($obj) : $obj; diff --git a/src/Debug/Utility/Table.php b/src/Debug/Utility/Table.php deleted file mode 100644 index c18f3038..00000000 --- a/src/Debug/Utility/Table.php +++ /dev/null @@ -1,477 +0,0 @@ - - * @license http://opensource.org/licenses/MIT MIT - * @copyright 2014-2025 Brad Kent - * @since 2.1 - */ - -namespace bdk\Debug\Utility; - -use bdk\Debug; -use bdk\Debug\Abstraction\Type; -use bdk\Debug\Utility\TableRow; - -/** - * Tablefy data. - * Ensure all row fields are in the same order - * - * @psalm-type meta = array{ - * columns: list, - * columnNames: array, - * tableInfo: array{ - * class: string|null, - * columns: array>, - * rows: array>, - * ..., - * }, - * totalCols: list, - * ..., - * } - */ -class Table -{ - /** @var Debug */ - private $debug; - /** @var meta */ - private $meta = array( - 'caption' => null, - 'columnNames' => array( - // specify column header label (may also specify via tableInfo/columns) - TableRow::SCALAR => 'value', - ), - 'columns' => [], // specify columns to collect/output - 'inclContext' => false, // for trace tables - 'sortable' => true, - 'tableInfo' => array( - 'class' => null, - 'columns' => array( - /* - array( - attribs - key // specify column header label (defaults to actual key or name specified in columnNames) - class // populated if all col values of the same class - falseAs: '' - total - trueAs: '' - ) - */ - ), - /* - 'commonRowInfo' => array( - // common info used for all rows.. only utilized when outputting - 'attribs' => array(), - 'class' => null, - 'keyOutput' => true, - 'summary' => '', - ), - */ - 'haveObjRow' => false, // if any row is an object (any object row will have rows[key]['class]) - 'indexLabel' => null, - 'rows' => array( - /* - key/index => array( - 'args' for traces - 'attribs - 'class' populated if row is an object - 'columns' - attribs - 'context' for traces - 'isScalar' - 'key' alternate key to display - 'summary' - ) - */ - ), - 'summary' => '', // if table is an obj... phpDoc summary - ), - 'totalCols' => array(), - ); - /** @var array */ - private $rows = array(); - - /** - * Constructor - * - * @param mixed $rows Table data - * @param array $meta Meta info / options - * @param Debug|null $debug Debug instance - */ - public function __construct($rows = array(), array $meta = array(), $debug = null) - { - \bdk\Debug\Utility\PhpType::assertType($debug, 'bdk\Debug|null', 'debug'); - - $this->debug = $debug ?: Debug::getInstance(); - $this->initMeta($meta); - $this->processRows($rows); - $this->setMeta(); - } - - /** - * Go through all the "rows" of array to determine what the keys are and their order - * - * @param array[]|TableRow[]|mixed[] $rows Array rows - * - * @return list - */ - public static function colKeys(array $rows) - { - $colKeys = array(); - foreach ($rows as $row) { - if (!$row instanceof TableRow) { - $row = new TableRow($row); - } - $curRowKeys = $row->keys(); - if ($curRowKeys !== $colKeys) { - $colKeys = self::colKeysMerge($curRowKeys, $colKeys); - } - } - return $colKeys; - } - - /** - * Get table rows - * - * @return array - */ - public function getRows() - { - return $this->rows; - } - - /** - * Get meta info - * - * @return meta - */ - public function getMeta() - { - return $this->meta; - } - - /** - * Do we have table data? - * - * @return bool - */ - public function haveRows() - { - return \is_array($this->rows) && \count($this->rows) > 0; - } - - /** - * Merge current row's keys with merged keys - * - * @param list $curRowKeys current row's keys - * @param list $colKeys all col keys - * - * @return list - */ - private static function colKeysMerge(array $curRowKeys, array $colKeys) - { - /** @var list */ - $newKeys = array(); - $count = \count($curRowKeys); - for ($i = 0; $i < $count; $i++) { - $curKey = $curRowKeys[$i]; - $position = \array_search($curKey, $colKeys, true); - if ($position !== false) { - $segment = \array_splice($colKeys, 0, (int) $position + 1); - /** @psalm-var list $newKeys */ - \array_splice($newKeys, \count($newKeys), 0, $segment); - } elseif (\in_array($curKey, $newKeys, true) === false) { - /** @psalm-var list $newKeys */ - $newKeys[] = $curKey; - } - } - // put on remaining colKeys - \array_splice($newKeys, \count($newKeys), 0, $colKeys); - /** @psalm-var list */ - return \array_values(\array_unique($newKeys)); - } - - /** - * Merge / initialize meta values - * - * @param array $meta Meta info / options - * - * @return void - */ - private function initMeta(array $meta) - { - /* - columns, columnNames, & totalCols will be moved to - tableInfo['columns'] structure - */ - /** @psalm-var meta */ - $this->meta = $this->debug->arrayUtil->mergeDeep($this->meta, $meta); - } - - /** - * Initialize this->meta['tableInfo']['columns'] - * - * @return void - */ - private function initTableInfoColumns() - { - $columnNames = $this->meta['columnNames']; - $keys = $this->meta['columns'] ?: self::colKeys($this->rows); - $columns = \array_fill_keys($keys, array()); - \array_walk($columns, function (&$column, $key) use ($columnNames) { - $default = array( - 'key' => isset($columnNames[$key]) - ? $columnNames[$key] - : $key, - ); - $column = \array_merge($default, isset($this->meta['tableInfo']['columns'][$key]) - ? $this->meta['tableInfo']['columns'][$key] - : array()); - }); - foreach ($this->meta['totalCols'] as $i => $key) { - if (isset($columns[$key]) === false) { - unset($this->meta['totalCols'][$i]); - continue; - } - $columns[$key]['total'] = null; - } - /** - * @psalm-suppress MixedArrayAssignment - * @psalm-suppress MixedPropertyTypeCoercion - */ - $this->meta['tableInfo']['columns'] = \array_map(static function (array $column) { - \ksort($column); - return $column; - }, $columns); - } - - /** - * Reduce each row to the columns specified - * Do this so we don't needlessly crate values that we won't output - * - * @param mixed $rows Table rows - * - * @return mixed - */ - private function preCrate($rows) - { - if (\is_array($rows) === false || empty($this->meta['columns'])) { - return $rows; - } - $colFlip = \array_flip($this->meta['columns']); - foreach ($rows as $i => $row) { - if (\is_array($row)) { - $rows[$i] = \array_intersect_key($row, $colFlip); - } - } - return $rows; - } - - /** - * non-array - * empty array - * array - * object / traversable - * - * @param mixed $rows Row data to process - * - * @return void - */ - private function processRows($rows) - { - if ($rows === null) { - return; - } - $rows = $this->processRowsGet($rows); - if (\is_array($rows) === false) { - return; - } - $this->rows = \array_map(static function ($row) { - return new TableRow($row); - }, $rows); - $this->initTableInfoColumns(); - foreach ($this->rows as $rowKey => $row) { - $this->rows[$rowKey] = $this->processRow($row, $rowKey); - } - } - - /** - * Get table rows - * - * @param mixed $rows Row data to process - * - * @return mixed - */ - private function processRowsGet($rows) - { - if ($this->meta['inclContext'] === false) { - $rows = $this->preCrate($rows); - } - $rows = $this->debug->abstracter->crate($rows, 'table'); - if ($this->debug->abstracter->isAbstraction($rows, Type::TYPE_OBJECT)) { - /** - * @psalm-var array{ - * classname: string, - * phpDoc: array{summary: string}, - * properties: array>, - * traverseValues?: array, - * } $rows - * - * - * @psalm-suppress MixedArrayAssignment - * @psalm-suppress MixedPropertyTypeCoercion pslam bug tableInfo becomes mixed - */ - $this->meta['tableInfo']['class'] = $rows['className']; - /** - * @psalm-suppress MixedArrayAssignment - * @psalm-suppress MixedPropertyTypeCoercion pslam bug tableInfo becomes mixed - * @psalm-suppress MixedArrayAccess - */ - $this->meta['tableInfo']['summary'] = $rows['phpDoc']['summary']; - /** @psalm-suppress MixedArgument */ - return $rows['traverseValues'] - ? $rows['traverseValues'] - : \array_map( - /** - * @param array{value:mixed,...} $info - */ - static function ($info) { - return $info['value']; - }, - \array_filter( - $rows['properties'], - /** - * @param array $prop - */ - static function ($prop) { - return \in_array('public', (array) $prop['visibility'], true); - } - ) - ); - } - return $rows; - } - - /** - * Process table row - * - * @param TableRow $row TableRow instance - * @param string|int $rowKey index of row - * - * @return array key => value - */ - private function processRow(TableRow $row, $rowKey) - { - $columns = $this->meta['tableInfo']['columns']; - $keys = \array_keys($columns); - $valsTemp = $row->keyValues($keys); - $rowInfo = $row->getInfo(); - if ($this->meta['inclContext']) { - $rowInfo['args'] = $row->getValue('args'); - $rowInfo['context'] = $row->getValue('context'); - } - $this->updateTableInfo($rowKey, $valsTemp, $rowInfo); - return \array_values($valsTemp); - } - - /** - * Set meta info - * - * @return void - */ - private function setMeta() - { - $this->setMetaTableInfoColumns(); - - if (isset($this->meta['columnNames'][TableRow::INDEX])) { - $this->meta['tableInfo']['indexLabel'] = $this->meta['columnNames'][TableRow::INDEX]; - } - - $this->meta = \array_diff_key($this->meta, \array_flip(['columnNames', 'columns', 'totalCols'])); - if (!$this->meta['inclContext']) { - unset($this->meta['inclContext']); - } - if (!$this->haveRows()) { - $this->meta = \array_diff_key($this->meta, \array_flip(['caption', 'inclContext', 'sortable', 'tableInfo'])); - } - } - - /** - * Set tableInfo['columns'] removing empty values - * - * @return void - */ - private function setMetaTableInfoColumns() - { - $this->meta['tableInfo']['columns'] = \array_values(\array_map(static function ($colInfo) { - return \array_filter($colInfo, static function ($val) { - return \is_array($val) - ? !empty($val) - : $val !== null && $val !== false; - }); - }, $this->meta['tableInfo']['columns'])); - } - - /** - * Update collected table info - * - * @param int|string $rowKey row's key/index - * @param array $rowValues row's values - * @param array $rowInfo Row info - * - * @return void - */ - private function updateTableInfo($rowKey, array $rowValues, array $rowInfo) - { - $this->meta['tableInfo']['haveObjRow'] = $this->meta['tableInfo']['haveObjRow'] || $rowInfo['class']; - foreach ($this->meta['totalCols'] as $key) { - /** - * @psalm-suppress MixedPropertyTypeCoercion - * @psalm-suppress MixedOperand - * @psalm-suppress PossiblyFalseOperand - */ - $this->meta['tableInfo']['columns'][$key]['total'] += $rowValues[$key]; - } - /** @var array-key $key */ - foreach ($rowInfo['classes'] as $key => $class) { - if (!isset($this->meta['tableInfo']['columns'][$key]['class'])) { - /** @psalm-suppress MixedPropertyTypeCoercion */ - $this->meta['tableInfo']['columns'][$key]['class'] = $class; - } elseif ($this->meta['tableInfo']['columns'][$key]['class'] !== $class) { - // column values not of the same type - /** @psalm-suppress MixedPropertyTypeCoercion */ - $this->meta['tableInfo']['columns'][$key]['class'] = false; - } - } - $this->updateTableInfoRow($rowKey, $rowInfo); - } - - /** - * Merge rowInfo into tableInfo['rows'][$rowKey] - * - * @param int|string $rowKey row's key/index - * @param array $rowInfo Row info - * - * @return void - */ - private function updateTableInfoRow($rowKey, array $rowInfo) - { - unset($rowInfo['classes']); - $rowInfo = \array_filter($rowInfo, static function ($val) { - return \in_array($val, [null, false, ''], true) === false; - }); - if (!$rowInfo) { - return; - } - // non-null/false values - $rowInfoExisting = isset($this->meta['tableInfo']['rows'][$rowKey]) - ? $this->meta['tableInfo']['rows'][$rowKey] - : array(); - /** - * @psalm-suppress MixedArrayAssignment - * @psalm-suppress MixedPropertyTypeCoercion - */ - $this->meta['tableInfo']['rows'][$rowKey] = \array_merge($rowInfoExisting, $rowInfo); - } -} diff --git a/src/Debug/Utility/TableRow.php b/src/Debug/Utility/TableRow.php deleted file mode 100644 index 3d20cbfc..00000000 --- a/src/Debug/Utility/TableRow.php +++ /dev/null @@ -1,208 +0,0 @@ - - * @license http://opensource.org/licenses/MIT MIT - * @copyright 2014-2025 Brad Kent - * @since 3.0b1 - */ - -namespace bdk\Debug\Utility; - -use bdk\Debug\Abstraction\Abstracter; -use bdk\Debug\Abstraction\Abstraction; -use bdk\Debug\Abstraction\Type; - -/** - * Represent a table row - * - * @psalm-type rowInfo = array{ - * class: string|null, - * classes: array, - * isScalar: bool, - * summary: string, - * } - */ -class TableRow -{ - const INDEX = "\x00index\x00"; - const SCALAR = "\x00scalar\x00"; - - /** @var array */ - private $row = array(); - - /** - * Will be populated with object info - * if row is an object, $info will be populated with - * 'class' & 'summary' - * if a value is an object being displayed as a string, - * $info['classes'][key] will be populated with className - * - * @var rowInfo - */ - private $info = array( - 'class' => null, - 'classes' => array(), // key => classname (or false if not stringified class) - 'isScalar' => false, - 'summary' => '', - ); - - /** - * Constructor - * - * @param mixed $row May be "scalar", array, abstraction (array, Traversable, object) - */ - public function __construct($row) - { - if (\is_array($row)) { - $this->row = $row; - return; - } - if ($row instanceof Abstraction) { - $this->row = $this->valuesAbs($row); - return; - } - $this->info['isScalar'] = true; - $this->row = array( - self::SCALAR => $row, - ); - } - - /** - * Get the collected row information - * - * @return rowInfo - */ - public function getInfo() - { - return $this->info; - } - - /** - * Get column value - * - * @param string|int $name column name - * @param bool $stringified return "stringified" value? - * - * @return mixed - */ - public function getValue($name, $stringified = true) - { - $value = \array_key_exists($name, $this->row) - ? $this->row[$name] - : Abstracter::UNDEFINED; - if ($stringified && $value instanceof Abstraction) { - // just return the stringified / __toString value in a table - if (isset($value['stringified'])) { - $this->info['classes'][$name] = $value['className']; - $value = $value['stringified']; - } elseif (isset($value['methods']['__toString']['returnValue'])) { - $this->info['classes'][$name] = $value['className']; - $value = $value['methods']['__toString']['returnValue']; - } - } - return $value; - } - - /** - * Get the row's keys - * - * @return list - */ - public function keys() - { - return \array_keys($this->row); - } - - /** - * Get values for passed keys - * - * @param array $keys column keys - * - * @return array key => value array - */ - public function keyValues($keys) - { - $values = array(); - foreach ($keys as $key) { - $this->info['classes'][$key] = false; - $values[$key] = $this->getValue($key); - } - if (\array_keys($values) === [self::SCALAR]) { - $this->info['isScalar'] = true; - } - return $values; - } - - /** - * Get values from abstraction - * - * @param Abstraction $abs Abstraction instance - * - * @return array - */ - private function valuesAbs(Abstraction $abs) - { - if ($abs['type'] !== Type::TYPE_OBJECT) { - // resource, callable, string, etc - $this->info['isScalar'] = true; - return array(self::SCALAR => $abs); - } - // we are an object - if (\strpos(\json_encode($abs['implements']), '"UnitEnum"') !== false) { - $this->info['isScalar'] = true; - return array(self::SCALAR => $abs); - } - if ($abs['className'] === 'Closure') { - $this->info['isScalar'] = true; - return array(self::SCALAR => $abs); - } - $this->info['class'] = $abs['className']; - $this->info['summary'] = $abs['phpDoc']['summary']; - $values = self::valuesAbsObj($abs); - if (\is_array($values) === false) { - // ie stringified value - $this->info['class'] = null; - $this->info['isScalar'] = true; - $values = array(self::SCALAR => $values); - } - return $values; - } - - /** - * Get object abstraction's values - * if, object has a stringified or __toString value, it will be returned - * - * @param Abstraction $abs Object Abstraction instance - * - * @return array|string - */ - private static function valuesAbsObj(Abstraction $abs) - { - if ($abs['traverseValues']) { - // probably Traversable - return $abs['traverseValues']; - } - if ($abs['stringified']) { - return $abs['stringified']; - } - if (isset($abs['methods']['__toString']['returnValue'])) { - return $abs['methods']['__toString']['returnValue']; - } - $values = \array_map( - static function ($info) { - return $info['value']; - }, - \array_filter($abs['properties'], static function ($prop) { - return \in_array('public', (array) $prop['visibility'], true); - }) - ); - /* - Reflection doesn't return properties in any given order - so, we'll sort for consistency - */ - \ksort($values, SORT_NATURAL | SORT_FLAG_CASE); - return $values; - } -} diff --git a/src/Debug/js/Debug.js b/src/Debug/js/Debug.js index abc29453..9b415746 100644 --- a/src/Debug/js/Debug.js +++ b/src/Debug/js/Debug.js @@ -712,6 +712,12 @@ var phpDebugConsole = (function (exports, $) { function buildFileHref (file, line, docRoot) { // console.warn('buildfileHref', {file, line, docRoot}) + if (typeof file === 'object') { + file = (file.docRoot ? 'DOCUMENT_ROOT' : '') + + (file.pathCommon ? file.pathCommon : '') + + (file.pathRel ? file.pathRel : '') + + (file.baseName ? file.baseName : ''); + } var data = { file: docRoot ? file.replace(/^DOCUMENT_ROOT\b/, docRoot) diff --git a/src/Debug/js/Debug.min.js b/src/Debug/js/Debug.min.js index 0f93ab6a..09f365eb 100644 --- a/src/Debug/js/Debug.min.js +++ b/src/Debug/js/Debug.min.js @@ -1 +1 @@ -var phpDebugConsole=function(e,t){"use strict";var n,a,i;function r(e){var a=e.find("> .array-inner");e.find(" > .t_array-expand").length>0||((a.html()||"").trim().length<1?e.addClass("expanded").find("br").hide():(!function(e){var a,i=e.find("> .array-inner");if(e.closest(".array-file-tree").length)return e.find("> .t_keyword, > .t_punct").remove(),i.find("> li > .t_operator, > li > .t_key.t_int").remove(),void e.prevAll(".t_key").each((function(){var n=t(this).attr("data-toggle","array");e.prepend(n),e.prepend('▾ ▸ ')}));a=t('array( ··· )'),e.find("> .t_keyword").first().wrap('').after('( ').parent().next().remove(),e.prepend(a)}(e),t.each(n.iconsArray,(function(t,n){e.find(n).prepend(t)})),e.debugEnhance(function(e){var t=e.data("expand"),n=e.parentsUntil(".m_group",".t_object, .t_array").length,a=0===n;void 0===t&&0!==n&&(t=e.closest(".t_array[data-expand]").data("expand"));void 0===t&&(t=a);return t||e.hasClass("array-file-tree")}(e)?"expand":"collapse")))}function o(e){a=e.data("config").get(),i=e.data("config").dict,e.on("click","[data-toggle=vis]",(function(){return function(e){var n=t(e),a=n.data("vis"),i=n.closest(".t_object"),r=i.find("> .object-inner"),o=r.find("[data-toggle=vis][data-vis="+a+"]"),s="inherited"===a?"dd[data-inherited-from], .private-ancestor":"."+a,c=r.find(s),l=n.hasClass("toggle-off");o.html(n.html().replace(l?"show ":"hide ",l?"hide ":"show ")).addClass(l?"toggle-on":"toggle-off").removeClass(l?"toggle-off":"toggle-on"),l?function(e){e.each((function(){var e=t(this),n=e.closest(".object-inner"),a=!0;n.find("> .vis-toggles [data-toggle]").each((function(){var n=t(this),i=n.hasClass("toggle-on"),r=n.data("vis"),o="inherited"===r?"dd[data-inherited-from], .private-ancestor":"."+r;if(!i&&1===e.filter(o).length)return a=!1,!1})),a&&e.show()}))}(c):c.hide(),f(i,!0)}(this),!1})),e.on("click","[data-toggle=interface]",(function(){return l(this),!1}))}function s(e){t.each(a.iconsObject,(function(n,a){var r=function(e,n){var a=n.match(/(?:parent(:\S+)\s)?(?:context(\S+)\s)?(.*)$/);if(null===a)return e.find(n);if(a[1]&&0===e.parent().filter(a[1]).length)return t();if(n=a[3],a[2])return e.filter(a[2]).find(n);return e.find(n)}(e,a),o="string"==typeof n?/^([ap])\s*:(.+)$/.exec(n):null,s=!o||"p"===o[1];if(o&&(n=o[2]),"function"==typeof n){var c=n;n=function(){return"object"==typeof(n=c.apply(this,arguments))&&(n=n[0].outerHTML),i.replaceTokens(n)}}else n=i.replaceTokens(n);s?function(e,t){var n=e.find("> i:first-child + i").after(t);e=e.not(n.parent()),n=e.find("> i:first-child").after(t),(e=e.not(n.parent())).prepend(t)}(r,n):r.append(n)}))}function c(e){var n=e.find("> .t_identifier").length?["> .t_identifier"]:["> .classname","> .t_const"],i=e.find("> .object-inner");e.find(n.join(",")).each((function(){var n=t(this),r="object"===n.data("toggle");i.is(".t_maxDepth, .t_recursion, .excluded, .t_punct")?n.addClass("empty"):r||0!==i.length&&(!function(e,n){if(!1===e.hasClass("prop-only"))return void n.wrap('').after(' ');var i=t(''+n[0].outerHTML+'( ··· )');n.wrap('').after('( ').parent().next(".t_punct").remove(),e.prepend(i)}(e,n),i.hide())})),e.debugEnhance(function(e){var t=e.data("expand"),n=e.parentsUntil(".m_group",".t_object, .t_array").length,a=0===n&&e.hasClass("prop-only");void 0===t&&0!==n&&(t=e.closest(".t_object[data-expand]").data("expand"));return void 0!==t?t:a}(e)?"expand":"collapse")}function l(e){var n=t(e),a=n.closest(".t_object");(n=n.is(".toggle-off")?n.add(n.next().find(".toggle-off")):n.add(n.next().find(".toggle-on"))).each((function(){var e=t(this),n=e.data("interface"),i=d(a,n);e.is(".toggle-off")?(e.addClass("toggle-on").removeClass("toggle-off"),i.show()):(e.addClass("toggle-off").removeClass("toggle-on"),i.hide())})),f(a)}function d(e,t){var n='> .object-inner > dd[data-implements="'+CSS.escape(t)+'"]';return e.find(n)}function f(e,n){var a=n?".object-inner > dt":"> .object-inner > dt",i=n?".object-inner > .heading":"> .object-inner > .heading";e.find(a).each((function(e){var n=t(e).nextUntil("dt"),a=n.not(".heading").filter((function(e){return"none"!==t(e).style("display")})),i=n.length>0&&0===a.length;t(e).toggleClass("text-muted",i)})),e.find(i).each((function(e){var n=t(e).nextUntil("dt, .heading"),a=n.filter((function(e){return"none"!==t(e).style("display")})),i=n.length>0&&0===a.length;t(e).toggleClass("text-muted",i)})),e.trigger("expanded.debug.object")}Object.keys=Object.keys||function(e){if(e!==Object(e))throw new TypeError("Object.keys called on a non-object");var t,n=[];for(t in e)Object.hasOwn(e,t)&&n.push(t);return n};var u,p,g,h,m,b,v=Object.freeze({__proto__:null,enhance:c,enhanceInner:function(e){var n=e.find("> .object-inner"),a=e.data("accessible"),i=null;e.is(".enhanced")||(n.find("> .private, > .protected").filter(".magic, .magic-read, .magic-write").removeClass("private protected"),"public"===a&&(n.find(".private, .protected").hide(),i="allDesc"),function(e){var n=e.find("> .object-inner");n.find("> dd.interface, > dd.implements .interface").each((function(){var n=t(this).text();0!==d(e,n).length&&t(this).addClass("toggle-on").prop("title","toggle interface methods").attr("data-toggle","interface").attr("data-interface",n)})).filter(".toggle-off").removeClass("toggle-off").each((function(){l(this)}))}(e),function(e,n){var a={hasProtected:e.children(".protected").not(".magic, .magic-read, .magic-write").length>0,hasPrivate:e.children(".private").not(".magic, .magic-read, .magic-write").length>0,hasExcluded:e.children(".debuginfo-excluded").hide().length>0,hasInherited:e.children("dd[data-inherited-from]").length>0},i=function(e,n){var a=t('
'),i="public"===n?"toggle-off":"toggle-on",r="public"===n?"show":"hide",o={hasProtected:''+r+" protected",hasPrivate:''+r+" private",hasExcluded:'show excluded',hasInherited:'hide inherited'};return t.each(e,(function(e,t){e&&a.append(o[t])})),a}(a,n);if(e.find("> dd[class*=t_modifier_]").length)return void e.find("> dd[class*=t_modifier_]").last().after(i);e.prepend(i)}(n,a),s(n),n.find("> .property.forceShow").show().find("> .t_array").debugEnhance("expand"),i&&f(e,"allDesc"===i),e.addClass("enhanced"))},init:o});function y(){var e=t(this).closest(".show-more-container");e.find(".show-more-wrapper").style("display","block").animate({height:"70px"}),e.find(".show-more-fade").fadeIn(),e.find(".show-more").show(),e.find(".show-less").hide()}function w(){var e=t(this).closest(".show-more-container");e.find(".show-more-wrapper").animate({height:e.find(".t_string").height()},400,"swing",(function(){t(this).style("display","inline")})),e.find(".show-more-fade").fadeOut(),e.find(".show-more").hide(),e.find(".show-less").show()}function x(e){var n=t(this).data("codePoint"),a="https://symbl.cc/en/"+n;e.stopPropagation(),n&&window.open(a,"unicode").focus()}function k(e){var n=t(e.target),a=n.closest("li[class*=m_]");e.stopPropagation(),n.find("> .array-inner > li > :last-child, > .array-inner > li[class]").each((function(){g(this,a)}))}function _(e){var n=t(e.target);e.stopPropagation(),n.find("> .group-body").debugEnhance()}function C(e){var n=t(e.target),a=n.closest("li[class*=m_]");e.stopPropagation(),n.is(".enhanced")||(n.find("> .object-inner").find("> .constant > :last-child,> .property > :last-child,> .method > ul > li > :last-child").each((function(){g(this,a)})),p.enhanceInner(n))}function O(e){var n=t(e.target);(n.hasClass("t_array")?n.find("> .array-inner").find("> li > .t_string, > li.t_string"):n.hasClass("m_group")?n.find("> .group-body > li > .t_string"):n.hasClass("group-body")?n.find("> li > .t_string"):n.hasClass("t_object")?n.find("> .object-inner").find(["> dd.constant > .t_string","> dd.property > .t_string","> dd.method > ul > li > .t_string.return-value"].join(", ")).filter(":visible"):t()).not("[data-type-more=numeric]").each((function(){var e,n,a;(e=t(this)).height()-70>35&&((a=e.wrap('
').parent()).append('
'),(n=a.wrap('
').parent()).append('"),n.append('"))}))}function E(e){var n=t(e),a=n.find("> thead");return n.is("table.sortable")?(n.addClass("table-sort"),a.on("click","th",(function(){var e=t(this),a=t(this).closest("tr").children(),i=a.index(e),r="desc"===(e.is(".sort-asc")?"asc":"desc")?"asc":"desc";a.removeClass("sort-asc sort-desc"),e.addClass("sort-"+r),e.find(".sort-arrows").length||(a.find(".sort-arrows").remove(),e.append('')),function(e,t,n){var a,i=e.tBodies[0],r=i.rows,o="function"==typeof Intl.Collator?new Intl.Collator([],{numeric:!0,sensitivity:"base"}):null;for(n="desc"===n?-1:1,r=(r=Array.prototype.slice.call(r,0)).sort(function(e,t,n){var a=/^([+-]?(?:0|[1-9]\d*)(?:\.\d*)?)(?:[eE]([+-]?\d+))?$/;return function(i,r){var o=i.cells[e],s=r.cells[e],c=o.textContent.trim(),l=s.textContent.trim(),d=o.getAttribute("data-type-more"),f=s.getAttribute("data-type-more"),u=c.match(a),p=l.match(a);return["true","false"].indexOf(d)>-1&&(c=d),["true","false"].indexOf(f)>-1&&(l=f),u&&p?t*function(e,t){if(et)return 1;return 0}(T(u),T(p)):t*function(e,t,n){return n?n.compare(e,t):e.localeCompare(t)}(c,l,n)}}(t,n,o)),a=0;a0;if(a){if(n)return void e.find("table tr:not(.context) > *:last-child").remove()}else e.find("table thead tr > *:last-child").after("");e.find("table tbody tr").each((function(){!function(e,n){var a=e.find("> td"),i={file:e.data("file")||a.eq(0).text(),line:e.data("line")||a.eq(1).text()},r=m.data("meta").DOCUMENT_ROOT??"",o=t("",{class:"file-link",href:A(i.file,i.line,r),html:'',style:"vertical-align: bottom",title:"Open in editor"});if(n)return void e.find(".file-link").replaceWith(o);if(e.hasClass("context"))return void a.eq(0).attr("colspan",parseInt(a.eq(0).attr("colspan"),10)+1);a.last().after(t("",{class:"text-center",html:o}))}(t(this),a)}))}(e,a):e.is("[data-file]")?function(e,n){var a=m.data("meta").DOCUMENT_ROOT;if(e.find("> .file-link").remove(),n)return;e.append(t("",{html:'',href:A(e.data("file"),e.data("line"),a),title:"Open in editor",class:"file-link lpad"})[0].outerHTML)}(e,a):t.each(n||t(),(function(){!function(e,n){var a=t(e),i="create";n?i="remove":a.hasClass("file-link")&&(i="update");if(a.closest(".m_trace").length)return;!function(e,n){var a=function(e,n){var a,i=m.data("meta").DOCUMENT_ROOT,r=function(e){var n=[],a=e.text().trim();if(e.data("file"))return[null,e.data("file"),e.data("line")||1];if(e.parent(".property.debug-value").find("> .t_identifier").text().match(/^file$/))return n={line:1},e.parent().parent().find("> .property.debug-value").each((function(){var e=t(this).find("> .t_identifier").text().trim(),a=t(this).find("> *:last-child").text().trim();n[e]=a})),[null,a,n.line];return a.match(/^(.+?)(?: \(.+? (\d+)(, .+ \d+)?\))?$/)||[]}(e);"update"===n?a=e.prop("href",A(r[1],r[2],i)):"create"===n?a=t("",{class:"file-link",href:A(r[1],r[2],i),html:e.html()+' ',title:"Open in editor"}):(e.find("i.fa-external-link").remove(),e.removeClass("file-link"),a=t("",{html:e.html()}));return function(e,n){var a=e[0].attributes;t.each(a,(function(){if(void 0!==this){var t=this.name;["html","href","title"].indexOf(t)>-1||(e.removeAttr(t),"class"!==t?n.attr(t,this.value):n.addClass(this.value))}}))}(e,a),a}(e,n);if(!1===e.is("li, td, th"))return void e.replaceWith(a);e.html("remove"===n?a.html():a)}(a,i)}(this,a)})))}function A(e,t,n){var a={file:n?e.replace(/^DOCUMENT_ROOT\b/,n):e,line:t||1};return h.linkFilesTemplate.replace(/%(\w*)\b/g,(function(e,t){return Object.hasOwn(a,t)?a[t]:""}))}var L,M,S,H,I=[],P=!1;function F(e){b=e.data("config").get(),function(e){n=e.data("config").get()}(e),o(e),function(e,n,a){u=e.data("config").dict,g=n,p=a,e.on("click",".close[data-dismiss=alert]",(function(){t(this).parent().remove()})),e.on("click",".show-more-container .show-less",y),e.on("click",".show-more-container .show-more",w),e.on("click",".char-ws, .unicode",x),e.on("expand.debug.array",k),e.on("expand.debug.group",_),e.on("expand.debug.object",C),e.on("expanded.debug.next",".context",(function(e){g(t(e.target).find("> td > .t_array"),t(e.target).closest("li"))})),e.on("expanded.debug.array expanded.debug.group expanded.debug.object",O)}(e,N,v),j(e)}function R(e){var n=e.parent(),a=!n.hasClass("m_group")||n.hasClass("expanded");e.hide(),e.children().each((function(){$(t(this))})),a&&e.show().trigger("expanded.debug.group"),function(){if(P)return;P=!0;for(;I.length;)I.shift().debugEnhance("expand");P=!1}(),!1===e.parent().hasClass("m_group")&&e.addClass("enhanced")}function $(e){if(!e.hasClass("enhanced")){if(e.hasClass("m_group"))!function(e){var n=e.find("> .group-header"),a=n.next();if(V(e),V(n),n.attr("data-toggle","group"),n.find(".t_array, .t_object").each((function(){t(this).data("expand",!1),N(this,e)})),e.hasClass("expanded")||a.find(".m_error, .m_warn").not(".filter-hidden").not("[data-uncollapse=false]").length)return void I.push(n);n.debugEnhance("collapse",!0)}(e);else{if(e.hasClass("filter-hidden"))return;e.is(".m_table, .m_trace")?function(e){D(e),V(e),e.hasClass("m_table")&&e.find("> table > tbody > tr > td").each((function(){N(this,e)}));e.find("tbody > tr.expanded").next().trigger("expanded.debug.next"),E(e.find("> table"))}(e):function(e){var t;e.data("file")&&(e.attr("title")||(t=e.data("file")+": line "+e.data("line"),e.data("evalline")&&(t+=" (eval'd line "+e.data("evalline")+")"),e.attr("title",t)),D(e));(e.hasClass("m_error")||e.hasClass("m_warn"))&&e.find(".m_trace").debugEnhance();V(e),e.children().each((function(){N(this,e)})),e.hasClass("have-fatal")&&D(e,e.find("[data-type-more=filepath]"))}(e)}e.addClass("enhanced"),e.trigger("enhanced.debug")}}function N(e,n){var a=t(e);a.is(".t_array")?r(a):a.is(".t_object")?c(a):a.is("table")?E(a):a.is(".string-encoded.tabs-container")?N(a.find("> .tab-pane.active > *"),n):a.is("[data-type-more=filepath], .t_string[data-file")&&D(n,a)}function V(e){var n=function(e){var n,a;if(e.data("icon"))return e.data("icon").match("<")?t(e.data("icon")):t("").addClass(e.data("icon"));if(e.hasClass("m_group"))return n;return a=e.hasClass("group-header")?e.parent():e,function(e){var n,a;for(a in b.iconsMethods)if(e.is(a)){n=t(b.iconsMethods[a]);break}return n}(a)}(e);!function(e){var n;for(n in b.iconsMisc)e.find(n).each((function(){var e=t(this),a=t(b.iconsMisc[n]);e.find("> i:first-child").hasClass(a.attr("class"))||e.prepend(a)}))}(e),n&&(e.hasClass("m_group")?e=e.find("> .group-header .group-label").eq(0):e.find("> table").length&&(e=function(e){var n=e.parent(".no-indent").length>0,a=e.find("> table > caption");0===a.length&&!1===n&&(a=t(""),e.find("> table").prepend(a));return a}(e)),e.find("> i:first-child").hasClass(n.attr("class"))||e.prepend(n))}function W(e){var t;(M=(L=e).data("config")).get("drawer")&&(L.addClass("debug-drawer debug-enhanced-ui"),(t=L.find(".debug-menu-bar")).before('
PHP
'),t.find(".float-right").append(''),L.find(".tab-panes").scrollLock(),L.find(".debug-resize-handle").on("mousedown",U),L.find(".debug-pull-tab").on("click",B),L.find(".debug-menu-bar .close").on("click",q),M.get("persistDrawer")&&M.get("openDrawer")&&B())}function B(e){e&&(L=t(e.target).closest(".debug-drawer")),L.addClass("debug-drawer-open"),L.debugEnhance(),G(),t(window).on("resize",G),M.get("persistDrawer")&&M.set("openDrawer",!0)}function q(e){e&&(L=t(e.target).closest(".debug-drawer")),L.removeClass("debug-drawer-open"),t(window).off("resize",G),M.get("persistDrawer")&&M.set("openDrawer",!1),G(0)}function U(e){t(e.target).closest(".debug-drawer").is(".debug-drawer-open")&&(S=L.find(".tab-panes").height(),H=e.pageY,t("html").addClass("debug-resizing"),L.parents().on("mousemove",z).on("mouseup",K),e.preventDefault())}function z(e){G(S+(H-e.pageY),!0)}function K(){t("html").removeClass("debug-resizing"),L.parents().off("mousemove",z).off("mouseup",K)}function G(e,n){var a=L.find(".tab-panes"),i=L.find(".debug-menu-bar").outerHeight(),r=window.innerHeight-i-50;if(0===e)return t("body").style("marginBottom",""),void L.trigger("resize.debug");e=function(e){var t=L.find(".tab-panes");if(e&&"object"!=typeof e)return e;e=parseInt(t[0].style.height,10),!e&&M.get("persistDrawer")&&(e=M.get("height"));return e||100}(e),e=Math.min(e,r),e=Math.max(e,20),a.height(e),t("body").style("marginBottom",L.height()+8+"px"),L.trigger("resize.debug"),n&&M.get("persistDrawer")&&M.set("height",e)}t.fn.scrollLock=function(e){return(e=void 0===e||e)?void t(this).on("DOMMouseScroll mousewheel wheel",(function(e){var n=t(this),a=this.scrollTop,i=this.scrollHeight,r=n.innerHeight(),o=e.wheelDelta,s=o>0,c=function(){return e.stopPropagation(),e.preventDefault(),e.returnValue=!1,!1};return!s&&-o>i-r-a?(n.scrollTop(i),c()):s&&o>a?(n.scrollTop(0),c()):void 0})):this.off("DOMMouseScroll mousewheel wheel")};var J,Y,X=[],Q=!0,Z=[function(e){var t=e.data("channel")||e.closest(".debug").data("channelKeyRoot");if(Q){if(e.is(".m_warn, .m_error")&&!1!==e.data("uncollapse"))return!0;if(e.is(".m_group")&&e.find(".m_error, .m_warn").not(".filter-hidden").length)return!0}return X.indexOf(t)>-1}],ee=[function(e){var n=e.find("input[data-toggle=channel]");0!==n.length?(X=[],n.filter(":checked").each((function(){X.push(t(this).val())}))):X=[e.data("channelKeyRoot")]}];function te(){var e=t(this),n=e.is(":checked"),a=e.closest("label").next("ul").find("input"),i=e.closest(".debug");e.closest(".debug-options").length>0||"error"!==e.data("toggle")&&(a.prop("checked",n),ae(i))}function ne(){var e=t(this),n=e.is(":checked"),a=e.closest(".debug"),i=".group-body .error-"+e.val();a.find(i).toggleClass("filter-hidden",!n),a.find(".m_error, .m_warn").parents(".m_group").trigger("collapsed.debug.group"),oe(a),re(a.find("> .tab-panes > .tab-pane.active"))}function ae(e){var n,a,i=e.data("channelKeyRoot"),r=[];for(n in ee)ee[n](e);for(e.find("> .tab-panes > .tab-primary > .tab-body").find(".m_alert, .group-body > *:not(.m_groupSummary)").each((function(){r.push({depth:t(this).parentsUntil(".tab_body").length,node:t(this)})})),r.sort((function(e,t){return e.depth .tab-panes > .tab-pane.active")),oe(e)}function ie(e,t){var n,a=e.is(".filter-hidden");e.data("channel")!==t+".phpError"&&(n=function(e){var t,n=!0;for(t in Z)if(!(n=Z[t](e)))break;return n}(e),e.toggleClass("filter-hidden",!n),n&&a?function(e){var t=e.parent().closest(".m_group");t.length&&!t.hasClass("expanded")||e.debugEnhance()}(e):n||a||function(e){e.hasClass("m_group")&&e.find("> .group-body").debugEnhance()}(e),n&&e.hasClass("m_group")&&e.trigger("collapsed.debug.group"))}function re(e){e.find("> .tab-body > hr").toggleClass("filter-hidden",e.find("> .tab-body").find(" > .debug-log-summary, > .debug-log").filter((function(){return t(this).height()<1})).length>0)}function oe(e){var t=e.find(".debug-sidebar input:checkbox:not(:checked)").length>0;e.toggleClass("filter-active",t)}function se(e){var t=e+"=",n=document.cookie.split(";"),a=null,i=0;for(i=0;i1?n[t[1]]:n}var de,fe,ue,pe='
';function ge(e){var t;Y=(J=e).data("config"),(t=J.find(".debug-menu-bar")).find(".float-right").prepend(''),pe=Y.dict.replaceTokens(pe),t.append(pe),Y.get("drawer")||t.find("input[name=persistDrawer]").closest("label").remove(),J.find(".debug-options-toggle").on("click",ve),J.find("select[name=theme]").on("change",ke).val(Y.get("theme")),J.find("input[name=debugCookie]").on("change",be).prop("checked",Y.get("debugKey")&&se("debug")===Y.get("debugKey")).prop("disabled",!Y.get("debugKey")).closest("label").toggleClass("disabled",!Y.get("debugKey")),J.find("input[name=persistDrawer]").on("change",xe).prop("checked",Y.get("persistDrawer")),J.find("input[name=linkFiles]").on("change",ye).prop("checked",Y.get("linkFiles")).trigger("change"),J.find("input[name=linkFilesTemplate]").on("change",we).val(Y.get("linkFilesTemplate"))}function he(e){0===J.find(".debug-options").find(e.target).length&&_e()}function me(e){27===e.keyCode&&_e()}function be(){t(this).is(":checked")?ce("debug",Y.get("debugKey"),7):ce("debug","",-1)}function ve(e){var n=t(this).closest(".debug-bar").find(".debug-options").is(".show");J=t(this).closest(".debug"),n?_e():(J.find(".debug-options").addClass("show"),t("body").on("click",he),t("body").on("keyup",me)),e.stopPropagation()}function ye(){var e=t(this).prop("checked"),n=t(this).closest(".debug-options").find("input[name=linkFilesTemplate]").closest(".form-group");e?n.slideDown():n.slideUp(),Y.set("linkFiles",e),t("input[name=linkFilesTemplate]").trigger("change")}function we(){var e=t(this).val();Y.set("linkFilesTemplate",e),J.trigger("config.debug.updated","linkFilesTemplate")}function xe(){var e=t(this).is(":checked");Y.set({persistDrawer:e,openDrawer:e,openSidebar:!0})}function ke(){Y.set("theme",t(this).val()),J.attr("data-theme",Y.themeGet())}function _e(){J.find(".debug-options").removeClass("show"),t("body").off("click",he),t("body").off("keyup",me)}var Ce,Oe,Ee,Te=!1,je={alert:'{string:side.alert}',error:'{string:side.error}',warn:'{string:side.warning}',info:'{string:side.info}',other:'{string:side.other}'};function De(e){var n;(de=e.data("config")||t("body").data("config"),(fe=e.find("> .tab-panes > .tab-primary").data("options")||{}).sidebar&&Ie(e),de.get("persistDrawer")&&!de.get("openSidebar")&&Pe(e),e.on("click",".close[data-dismiss=alert]",Le),e.on("click",".sidebar-toggle",Me),e.on("change",".debug-sidebar input[type=checkbox]",Ae),Te)||(n=He,ee.push(n),function(e){Z.push(e)}(Se),Te=!0)}function Ae(e){var n=t(this),a=n.closest(".toggle"),i=a.next("ul").find(".toggle"),r=n.is(":checked"),o=t(".m_alert.error-summary.have-fatal");a.toggleClass("active",r),i.toggleClass("active",r),"fatal"===n.val()&&(o.find(".error-fatal").toggleClass("filter-hidden",!r),o.toggleClass("filter-hidden",0===o.children().not(".filter-hidden").length))}function Le(e){var n=t(e.delegateTarget);setTimeout((function(){0===n.find(".tab-primary > .tab-body > .m_alert").length&&n.find(".debug-sidebar input[data-toggle=method][value=alert]").parent().addClass("disabled")}))}function Me(){var e=t(this).closest(".debug");e.find(".debug-sidebar").is(".show")?Pe(e):Fe(e)}function Se(e){var t=e[0].className.match(/\bm_(\S+)\b/),n=t?t[1]:null;return!fe.sidebar||("group"===n&&e.find("> .group-body")[0].className.match(/level-(error|info|warn)/)&&(n=e.find("> .group-body")[0].className.match(/level-(error|info|warn)/)[1],e.toggleClass("filter-hidden-body",ue.indexOf(n)<0)),["alert","error","warn","info"].indexOf(n)>-1?ue.indexOf(n)>-1:ue.indexOf("other")>-1)}function He(e){var n=e.find(".tab-pane.active .debug-sidebar");ue=[],0===n.length&&(ue=Object.keys(je)),n.find("input[data-toggle=method]:checked").each((function(){ue.push(t(this).val())}))}function Ie(e){var n=t(de.dict.replaceTokens('
')),a=e.find(".tab-panes > .tab-primary > .tab-body > .expand-all");e.find(".tab-panes > .tab-primary > .tab-body").before(n),function(e){var t=e.closest(".debug").find(".m_alert.error-summary"),n=t.find(".in-console");n.prev().remove(),n.remove(),0===t.children().length&&t.remove()}(e),function(e){var n=e.find(".debug-sidebar .php-errors ul"),a=["fatal","error","warning","deprecated","notice","strict"];t.each(a,(function(a){var i="fatal"===a?e.find(".m_alert.error-summary.have-fatal").length:e.find(".error-"+a).filter(".m_error,.m_warn").length;0!==i&&n.append(t("
  • ").append(t('
  • ").append(t('