Browse Source

Merge branch 'master' into 3.next

Mark Story 9 years ago
parent
commit
d7c4912f6a

+ 67 - 67
src/Database/Query.php

@@ -236,11 +236,11 @@ class Query implements ExpressionInterface, IteratorAggregate
      *
      * ### Example:
      * ```
-     *  $query->select(['title'])->from('articles')->traverse(function ($value, $clause) {
-     *      if ($clause === 'select') {
-     *          var_dump($value);
-     *      }
-     *  }, ['select', 'from']);
+     * $query->select(['title'])->from('articles')->traverse(function ($value, $clause) {
+     *     if ($clause === 'select') {
+     *         var_dump($value);
+     *     }
+     * }, ['select', 'from']);
      * ```
      *
      * @param callable $visitor A function or callable to be executed for each part
@@ -410,10 +410,10 @@ class Query implements ExpressionInterface, IteratorAggregate
      * ### Examples:
      *
      * ```
-     *  $query->from(['p' => 'posts']); // Produces FROM posts p
-     *  $query->from('authors'); // Appends authors: FROM posts p, authors
-     *  $query->from(['products'], true); // Resets the list: FROM products
-     *  $query->from(['sub' => $countQuery]); // FROM (SELECT ...) sub
+     * $query->from(['p' => 'posts']); // Produces FROM posts p
+     * $query->from('authors'); // Appends authors: FROM posts p, authors
+     * $query->from(['products'], true); // Resets the list: FROM products
+     * $query->from(['sub' => $countQuery]); // FROM (SELECT ...) sub
      * ```
      *
      * @param array|\Cake\Database\ExpressionInterface|string $tables tables to be added to the list
@@ -457,33 +457,33 @@ class Query implements ExpressionInterface, IteratorAggregate
      * A join can be fully described and aliased using the array notation:
      *
      * ```
-     *  $query->join([
-     *      'a' => [
-     *          'table' => 'authors',
-     *          'type' => 'LEFT',
-     *          'conditions' => 'a.id = b.author_id'
-     *      ]
-     *  ]);
-     *  // Produces LEFT JOIN authors a ON a.id = b.author_id
+     * $query->join([
+     *     'a' => [
+     *         'table' => 'authors',
+     *         'type' => 'LEFT',
+     *         'conditions' => 'a.id = b.author_id'
+     *     ]
+     * ]);
+     * // Produces LEFT JOIN authors a ON a.id = b.author_id
      * ```
      *
      * You can even specify multiple joins in an array, including the full description:
      *
      * ```
-     *  $query->join([
-     *      'a' => [
-     *          'table' => 'authors',
-     *          'type' => 'LEFT',
-     *          'conditions' => 'a.id = b.author_id'
-     *      ],
-     *      'p' => [
-     *          'table' => 'publishers',
-     *          'type' => 'INNER',
-     *          'conditions' => 'p.id = b.publisher_id AND p.name = "Cake Software Foundation"'
-     *      ]
-     *  ]);
-     *  // LEFT JOIN authors a ON a.id = b.author_id
-     *  // INNER JOIN publishers p ON p.id = b.publisher_id AND p.name = "Cake Software Foundation"
+     * $query->join([
+     *     'a' => [
+     *         'table' => 'authors',
+     *         'type' => 'LEFT',
+     *         'conditions' => 'a.id = b.author_id'
+     *     ],
+     *     'p' => [
+     *         'table' => 'publishers',
+     *         'type' => 'INNER',
+     *         'conditions' => 'p.id = b.publisher_id AND p.name = "Cake Software Foundation"'
+     *     ]
+     * ]);
+     * // LEFT JOIN authors a ON a.id = b.author_id
+     * // INNER JOIN publishers p ON p.id = b.publisher_id AND p.name = "Cake Software Foundation"
      * ```
      *
      * ### Using conditions and types
@@ -497,14 +497,14 @@ class Query implements ExpressionInterface, IteratorAggregate
      * using the second parameter of this function.
      *
      * ```
-     *  $query->join(['a' => [
-     *      'table' => 'articles',
-     *      'conditions' => [
-     *          'a.posted >=' => new DateTime('-3 days'),
-     *          'a.published' => true,
-     *          'a.author_id = authors.id'
-     *      ]
-     *  ]], ['a.posted' => 'datetime', 'a.published' => 'boolean'])
+     * $query->join(['a' => [
+     *     'table' => 'articles',
+     *     'conditions' => [
+     *         'a.posted >=' => new DateTime('-3 days'),
+     *         'a.published' => true,
+     *         'a.author_id = authors.id'
+     *     ]
+     * ]], ['a.posted' => 'datetime', 'a.published' => 'boolean'])
      * ```
      *
      * ### Overwriting joins
@@ -515,9 +515,9 @@ class Query implements ExpressionInterface, IteratorAggregate
      * with another list if the third parameter for this function is set to true.
      *
      * ```
-     *  $query->join(['alias' => 'table']); // joins table with as alias
-     *  $query->join(['alias' => 'another_table']); // joins another_table with as alias
-     *  $query->join(['something' => 'different_table'], [], true); // resets joins list
+     * $query->join(['alias' => 'table']); // joins table with as alias
+     * $query->join(['alias' => 'another_table']); // joins another_table with as alias
+     * $query->join(['something' => 'different_table'], [], true); // resets joins list
      * ```
      *
      * @param array|string|null $tables list of tables to be joined in the query
@@ -707,11 +707,11 @@ class Query implements ExpressionInterface, IteratorAggregate
      * ### Conditions using operators:
      *
      * ```
-     *  $query->where([
-     *      'posted >=' => new DateTime('3 days ago'),
-     *      'title LIKE' => 'Hello W%',
-     *      'author_id' => 1,
-     *  ], ['posted' => 'datetime']);
+     * $query->where([
+     *     'posted >=' => new DateTime('3 days ago'),
+     *     'title LIKE' => 'Hello W%',
+     *     'author_id' => 1,
+     * ], ['posted' => 'datetime']);
      * ```
      *
      * The previous example produces:
@@ -724,11 +724,11 @@ class Query implements ExpressionInterface, IteratorAggregate
      * ### Nesting conditions with conjunctions:
      *
      * ```
-     *  $query->where([
-     *      'author_id !=' => 1,
-     *      'OR' => ['published' => true, 'posted <' => new DateTime('now')],
-     *      'NOT' => ['title' => 'Hello']
-     *  ], ['published' => boolean, 'posted' => 'datetime']
+     * $query->where([
+     *     'author_id !=' => 1,
+     *     'OR' => ['published' => true, 'posted <' => new DateTime('now')],
+     *     'NOT' => ['title' => 'Hello']
+     * ], ['published' => boolean, 'posted' => 'datetime']
      * ```
      *
      * The previous example produces:
@@ -749,8 +749,8 @@ class Query implements ExpressionInterface, IteratorAggregate
      * ### Using expressions objects:
      *
      * ```
-     *  $exp = $query->newExpr()->add(['id !=' => 100, 'author_id' != 1])->tieWith('OR');
-     *  $query->where(['published' => true], ['published' => 'boolean'])->where($exp);
+     * $exp = $query->newExpr()->add(['id !=' => 100, 'author_id' != 1])->tieWith('OR');
+     * $query->where(['published' => true], ['published' => 'boolean'])->where($exp);
      * ```
      *
      * The previous example produces:
@@ -767,13 +767,13 @@ class Query implements ExpressionInterface, IteratorAggregate
      * added the list of conditions for the query using the AND operator.
      *
      * ```
-     *  $query
-     *  ->where(['title !=' => 'Hello World'])
-     *  ->where(function ($exp, $query) {
-     *      $or = $exp->or_(['id' => 1]);
-     *      $and = $exp->and_(['id >' => 2, 'id <' => 10]);
-     *  return $or->add($and);
-     *  });
+     * $query
+     *   ->where(['title !=' => 'Hello World'])
+     *   ->where(function ($exp, $query) {
+     *     $or = $exp->or_(['id' => 1]);
+     *     $and = $exp->and_(['id >' => 2, 'id <' => 10]);
+     *    return $or->add($and);
+     *   });
      * ```
      *
      * * The previous example produces:
@@ -783,7 +783,7 @@ class Query implements ExpressionInterface, IteratorAggregate
      * ### Conditions as strings:
      *
      * ```
-     *  $query->where(['articles.author_id = authors.id', 'modified IS NULL']);
+     * $query->where(['articles.author_id = authors.id', 'modified IS NULL']);
      * ```
      *
      * The previous example produces:
@@ -1213,8 +1213,8 @@ class Query implements ExpressionInterface, IteratorAggregate
      * ### Examples
      *
      * ```
-     *  $query->offset(10) // generates OFFSET 10
-     *  $query->offset($query->newExpr()->add(['1 + 1'])); // OFFSET (1 + 1)
+     * $query->offset(10) // generates OFFSET 10
+     * $query->offset($query->newExpr()->add(['1 + 1'])); // OFFSET (1 + 1)
      * ```
      *
      * @param int|\Cake\Database\ExpressionInterface $num number of records to be skipped
@@ -1242,8 +1242,8 @@ class Query implements ExpressionInterface, IteratorAggregate
      * ### Examples
      *
      * ```
-     *  $union = (new Query($conn))->select(['id', 'title'])->from(['a' => 'articles']);
-     *  $query->select(['id', 'name'])->from(['d' => 'things'])->union($union);
+     * $union = (new Query($conn))->select(['id', 'title'])->from(['a' => 'articles']);
+     * $query->select(['id', 'name'])->from(['d' => 'things'])->union($union);
      * ```
      *
      * Will produce:
@@ -1414,7 +1414,7 @@ class Query implements ExpressionInterface, IteratorAggregate
      *
      * ```
      * $query->update('articles')->set(function ($exp) {
-     *  return $exp->eq('title', 'The title', 'string');
+     *   return $exp->eq('title', 'The title', 'string');
      * });
      * ```
      *

+ 4 - 4
src/ORM/Behavior/TreeBehavior.php

@@ -103,11 +103,11 @@ class TreeBehavior extends Behavior
         $dirty = $entity->dirty($config['parent']);
         $level = $config['level'];
 
-        if ($isNew && $parent) {
-            if ($entity->get($primaryKey[0]) == $parent) {
-                throw new RuntimeException("Cannot set a node's parent as itself");
-            }
+        if ($parent && $entity->get($primaryKey) == $parent) {
+            throw new RuntimeException("Cannot set a node's parent as itself");
+        }
 
+        if ($isNew && $parent) {
             $parentNode = $this->_getNode($parent);
             $edge = $parentNode->get($config['right']);
             $entity->set($config['left'], $edge);

+ 80 - 80
src/ORM/Query.php

@@ -247,11 +247,11 @@ class Query extends DatabaseQuery implements JsonSerializable, QueryInterface
      * ### Example:
      *
      * ```
-     *  // Bring articles' author information
-     *  $query->contain('Author');
+     * // Bring articles' author information
+     * $query->contain('Author');
      *
-     *  // Also bring the category and tags associated to each article
-     *  $query->contain(['Category', 'Tag']);
+     * // Also bring the category and tags associated to each article
+     * $query->contain(['Category', 'Tag']);
      * ```
      *
      * Associations can be arbitrarily nested using dot notation or nested arrays,
@@ -261,14 +261,14 @@ class Query extends DatabaseQuery implements JsonSerializable, QueryInterface
      * ### Example:
      *
      * ```
-     *  // Eager load the product info, and for each product load other 2 associations
-     *  $query->contain(['Product' => ['Manufacturer', 'Distributor']);
+     * // Eager load the product info, and for each product load other 2 associations
+     * $query->contain(['Product' => ['Manufacturer', 'Distributor']);
      *
-     *  // Which is equivalent to calling
-     *  $query->contain(['Products.Manufactures', 'Products.Distributors']);
+     * // Which is equivalent to calling
+     * $query->contain(['Products.Manufactures', 'Products.Distributors']);
      *
-     *  // For an author query, load his region, state and country
-     *  $query->contain('Regions.States.Countries');
+     * // For an author query, load his region, state and country
+     * $query->contain('Regions.States.Countries');
      * ```
      *
      * It is possible to control the conditions and fields selected for each of the
@@ -277,13 +277,13 @@ class Query extends DatabaseQuery implements JsonSerializable, QueryInterface
      * ### Example:
      *
      * ```
-     *  $query->contain(['Tags' => function ($q) {
-     *      return $q->where(['Tags.is_popular' => true]);
-     *  }]);
+     * $query->contain(['Tags' => function ($q) {
+     *     return $q->where(['Tags.is_popular' => true]);
+     * }]);
      *
-     *  $query->contain(['Products.Manufactures' => function ($q) {
-     *      return $q->select(['name'])->where(['Manufactures.active' => true]);
-     *  }]);
+     * $query->contain(['Products.Manufactures' => function ($q) {
+     *     return $q->select(['name'])->where(['Manufactures.active' => true]);
+     * }]);
      * ```
      *
      * Each association might define special options when eager loaded, the allowed
@@ -404,10 +404,10 @@ class Query extends DatabaseQuery implements JsonSerializable, QueryInterface
      * ### Example:
      *
      * ```
-     *  // Bring only articles that were tagged with 'cake'
-     *  $query->matching('Tags', function ($q) {
-     *      return $q->where(['name' => 'cake']);
-     *  );
+     * // Bring only articles that were tagged with 'cake'
+     * $query->matching('Tags', function ($q) {
+     *     return $q->where(['name' => 'cake']);
+     * );
      * ```
      *
      * It is possible to filter by deep associations by using dot notation:
@@ -415,10 +415,10 @@ class Query extends DatabaseQuery implements JsonSerializable, QueryInterface
      * ### Example:
      *
      * ```
-     *  // Bring only articles that were commented by 'markstory'
-     *  $query->matching('Comments.Users', function ($q) {
-     *      return $q->where(['username' => 'markstory']);
-     *  );
+     * // Bring only articles that were commented by 'markstory'
+     * $query->matching('Comments.Users', function ($q) {
+     *     return $q->where(['username' => 'markstory']);
+     * );
      * ```
      *
      * As this function will create `INNER JOIN`, you might want to consider
@@ -429,11 +429,11 @@ class Query extends DatabaseQuery implements JsonSerializable, QueryInterface
      * ### Example:
      *
      * ```
-     *  // Bring unique articles that were commented by 'markstory'
-     *  $query->distinct(['Articles.id'])
-     *  ->matching('Comments.Users', function ($q) {
-     *      return $q->where(['username' => 'markstory']);
-     *  );
+     * // Bring unique articles that were commented by 'markstory'
+     * $query->distinct(['Articles.id'])
+     * ->matching('Comments.Users', function ($q) {
+     *     return $q->where(['username' => 'markstory']);
+     * );
      * ```
      *
      * Please note that the query passed to the closure will only accept calling
@@ -462,34 +462,34 @@ class Query extends DatabaseQuery implements JsonSerializable, QueryInterface
      * ### Example:
      *
      * ```
-     *  // Get the count of articles per user
-     *  $usersQuery
-     *      ->select(['total_articles' => $query->func()->count('Articles.id')])
-     *      ->leftJoinWith('Articles')
-     *      ->group(['Users.id'])
-     *      ->autoFields(true);
+     * // Get the count of articles per user
+     * $usersQuery
+     *     ->select(['total_articles' => $query->func()->count('Articles.id')])
+     *     ->leftJoinWith('Articles')
+     *     ->group(['Users.id'])
+     *     ->autoFields(true);
      * ```
      *
      * You can also customize the conditions passed to the LEFT JOIN:
      *
      * ```
-     *  // Get the count of articles per user with at least 5 votes
-     *  $usersQuery
-     *      ->select(['total_articles' => $query->func()->count('Articles.id')])
-     *      ->leftJoinWith('Articles', function ($q) {
-     *          return $q->where(['Articles.votes >=' => 5]);
-     *      })
-     *      ->group(['Users.id'])
-     *      ->autoFields(true);
+     * // Get the count of articles per user with at least 5 votes
+     * $usersQuery
+     *     ->select(['total_articles' => $query->func()->count('Articles.id')])
+     *     ->leftJoinWith('Articles', function ($q) {
+     *         return $q->where(['Articles.votes >=' => 5]);
+     *     })
+     *     ->group(['Users.id'])
+     *     ->autoFields(true);
      * ```
      *
      * This will create the following SQL:
      *
      * ```
-     *  SELECT COUNT(Articles.id) AS total_articles, Users.*
-     *  FROM users Users
-     *  LEFT JOIN articles Articles ON Articles.user_id = Users.id AND Articles.votes >= 5
-     *  GROUP BY USers.id
+     * SELECT COUNT(Articles.id) AS total_articles, Users.*
+     * FROM users Users
+     * LEFT JOIN articles Articles ON Articles.user_id = Users.id AND Articles.votes >= 5
+     * GROUP BY USers.id
      * ```
      *
      * It is possible to left join deep associations by using dot notation
@@ -497,13 +497,13 @@ class Query extends DatabaseQuery implements JsonSerializable, QueryInterface
      * ### Example:
      *
      * ```
-     *  // Total comments in articles by 'markstory'
-     *  $query
-     *   ->select(['total_comments' => $query->func()->count('Comments.id')])
-     *   ->leftJoinWith('Comments.Users', function ($q) {
-     *      return $q->where(['username' => 'markstory']);
-     *  )
-     *  ->group(['Users.id']);
+     * // Total comments in articles by 'markstory'
+     * $query
+     *  ->select(['total_comments' => $query->func()->count('Comments.id')])
+     *  ->leftJoinWith('Comments.Users', function ($q) {
+     *     return $q->where(['username' => 'markstory']);
+     * )
+     * ->group(['Users.id']);
      * ```
      *
      * Please note that the query passed to the closure will only accept calling
@@ -535,20 +535,20 @@ class Query extends DatabaseQuery implements JsonSerializable, QueryInterface
      * ### Example:
      *
      * ```
-     *  // Bring only articles that were tagged with 'cake'
-     *  $query->innerJoinWith('Tags', function ($q) {
-     *      return $q->where(['name' => 'cake']);
-     *  );
+     * // Bring only articles that were tagged with 'cake'
+     * $query->innerJoinWith('Tags', function ($q) {
+     *     return $q->where(['name' => 'cake']);
+     * );
      * ```
      *
      * This will create the following SQL:
      *
      * ```
-     *  SELECT Articles.*
-     *  FROM articles Articles
-     *  INNER JOIN tags Tags ON Tags.name = 'cake'
-     *  INNER JOIN articles_tags ArticlesTags ON ArticlesTags.tag_id = Tags.id
-     *    AND ArticlesTags.articles_id = Articles.id
+     * SELECT Articles.*
+     * FROM articles Articles
+     * INNER JOIN tags Tags ON Tags.name = 'cake'
+     * INNER JOIN articles_tags ArticlesTags ON ArticlesTags.tag_id = Tags.id
+     *   AND ArticlesTags.articles_id = Articles.id
      * ```
      *
      * This function works the same as `matching()` with the difference that it
@@ -579,10 +579,10 @@ class Query extends DatabaseQuery implements JsonSerializable, QueryInterface
      * ### Example:
      *
      * ```
-     *  // Bring only articles that were not tagged with 'cake'
-     *  $query->notMatching('Tags', function ($q) {
-     *      return $q->where(['name' => 'cake']);
-     *  );
+     * // Bring only articles that were not tagged with 'cake'
+     * $query->notMatching('Tags', function ($q) {
+     *     return $q->where(['name' => 'cake']);
+     * );
      * ```
      *
      * It is possible to filter by deep associations by using dot notation:
@@ -590,10 +590,10 @@ class Query extends DatabaseQuery implements JsonSerializable, QueryInterface
      * ### Example:
      *
      * ```
-     *  // Bring only articles that weren't commented by 'markstory'
-     *  $query->notMatching('Comments.Users', function ($q) {
-     *      return $q->where(['username' => 'markstory']);
-     *  );
+     * // Bring only articles that weren't commented by 'markstory'
+     * $query->notMatching('Comments.Users', function ($q) {
+     *     return $q->where(['username' => 'markstory']);
+     * );
      * ```
      *
      * As this function will create a `LEFT JOIN`, you might want to consider
@@ -604,11 +604,11 @@ class Query extends DatabaseQuery implements JsonSerializable, QueryInterface
      * ### Example:
      *
      * ```
-     *  // Bring unique articles that were commented by 'markstory'
-     *  $query->distinct(['Articles.id'])
-     *  ->notMatching('Comments.Users', function ($q) {
-     *      return $q->where(['username' => 'markstory']);
-     *  );
+     * // Bring unique articles that were commented by 'markstory'
+     * $query->distinct(['Articles.id'])
+     * ->notMatching('Comments.Users', function ($q) {
+     *     return $q->where(['username' => 'markstory']);
+     * );
      * ```
      *
      * Please note that the query passed to the closure will only accept calling
@@ -663,10 +663,10 @@ class Query extends DatabaseQuery implements JsonSerializable, QueryInterface
      * Is equivalent to:
      *
      * ```
-     *  $query
-     *  ->select(['id', 'name'])
-     *  ->where(['created >=' => '2013-01-01'])
-     *  ->limit(10)
+     * $query
+     *   ->select(['id', 'name'])
+     *   ->where(['created >=' => '2013-01-01'])
+     *   ->limit(10)
      * ```
      */
     public function applyOptions(array $options)

+ 4 - 4
src/Shell/Helper/TableHelper.php

@@ -43,10 +43,10 @@ class TableHelper extends Helper
     {
         $widths = [];
         foreach ($rows as $line) {
-            for ($i = 0, $len = count($line); $i < $len; $i++) {
-                $columnLength = mb_strlen($line[$i]);
-                if ($columnLength > (isset($widths[$i]) ? $widths[$i] : 0)) {
-                    $widths[$i] = $columnLength;
+            foreach ($line as $k => $v) {
+                $columnLength = mb_strlen($line[$k]);
+                if ($columnLength > (isset($widths[$k]) ? $widths[$k] : 0)) {
+                    $widths[$k] = $columnLength;
                 }
             }
         }

+ 11 - 2
src/TestSuite/IntegrationTestCase.php

@@ -509,10 +509,15 @@ abstract class IntegrationTestCase extends TestCase
         $session = Session::create($sessionConfig);
         $session->write($this->_session);
         list ($url, $query) = $this->_url($url);
+        $tokenUrl = $url;
+
+        if ($query) {
+            $tokenUrl .= '?' . http_build_query($query);
+        }
 
         $props = [
             'url' => $url,
-            'post' => $this->_addTokens($url, $data),
+            'post' => $this->_addTokens($tokenUrl, $data),
             'cookies' => $this->_cookie,
             'session' => $session,
             'query' => $query
@@ -523,7 +528,11 @@ abstract class IntegrationTestCase extends TestCase
         $env = [];
         if (isset($this->_request['headers'])) {
             foreach ($this->_request['headers'] as $k => $v) {
-                $env['HTTP_' . str_replace('-', '_', strtoupper($k))] = $v;
+                $name = strtoupper(str_replace('-', '_', $k));
+                if (!in_array($name, ['CONTENT_LENGTH', 'CONTENT_TYPE'])) {
+                    $name = 'HTTP_' . $name;
+                }
+                $env[$name] = $v;
             }
             unset($this->_request['headers']);
         }

+ 214 - 82
src/Utility/Text.php

@@ -554,6 +554,7 @@ class Text
      * - `ellipsis` Will be used as ending and appended to the trimmed string
      * - `exact` If false, $text will not be cut mid-word
      * - `html` If true, HTML tags would be handled correctly
+     * - `trimWidth` If true, $text will be truncated with the width
      *
      * @param string $text String to truncate.
      * @param int $length Length of returned string, including ellipsis.
@@ -564,124 +565,255 @@ class Text
     public static function truncate($text, $length = 100, array $options = [])
     {
         $default = [
-            'ellipsis' => '...', 'exact' => true, 'html' => false
+            'ellipsis' => '...', 'exact' => true, 'html' => false, 'trimWidth' => false,
         ];
         if (!empty($options['html']) && strtolower(mb_internal_encoding()) === 'utf-8') {
             $default['ellipsis'] = "\xe2\x80\xa6";
         }
         $options += $default;
 
+        $prefix = '';
+        $suffix = $options['ellipsis'];
+
         if ($options['html']) {
-            if (mb_strlen(preg_replace('/<.*?>/', '', $text)) <= $length) {
-                return $text;
-            }
-            $totalLength = mb_strlen(strip_tags($options['ellipsis']));
+            $ellipsisLength = self::_strlen(strip_tags($options['ellipsis']), $options);
+
+            $truncateLength = 0;
+            $totalLength = 0;
             $openTags = [];
             $truncate = '';
 
             preg_match_all('/(<\/?([\w+]+)[^>]*>)?([^<>]*)/', $text, $tags, PREG_SET_ORDER);
             foreach ($tags as $tag) {
-                if (!preg_match('/img|br|input|hr|area|base|basefont|col|frame|isindex|link|meta|param/s', $tag[2])) {
-                    if (preg_match('/<[\w]+[^>]*>/s', $tag[0])) {
-                        array_unshift($openTags, $tag[2]);
-                    } elseif (preg_match('/<\/([\w]+)[^>]*>/s', $tag[0], $closeTag)) {
-                        $pos = array_search($closeTag[1], $openTags);
-                        if ($pos !== false) {
-                            array_splice($openTags, $pos, 1);
-                        }
-                    }
-                }
-                $truncate .= $tag[1];
-
-                $contentLength = mb_strlen(preg_replace('/&[0-9a-z]{2,8};|&#[0-9]{1,7};|&#x[0-9a-f]{1,6};/i', ' ', $tag[3]));
-                if ($contentLength + $totalLength > $length) {
-                    $left = $length - $totalLength;
-                    $entitiesLength = 0;
-                    if (preg_match_all('/&[0-9a-z]{2,8};|&#[0-9]{1,7};|&#x[0-9a-f]{1,6};/i', $tag[3], $entities, PREG_OFFSET_CAPTURE)) {
-                        foreach ($entities[0] as $entity) {
-                            if ($entity[1] + 1 - $entitiesLength <= $left) {
-                                $left--;
-                                $entitiesLength += mb_strlen($entity[0]);
-                            } else {
-                                break;
+                $contentLength = self::_strlen($tag[3], $options);
+
+                if ($truncate === '') {
+                    if (!preg_match('/img|br|input|hr|area|base|basefont|col|frame|isindex|link|meta|param/i', $tag[2])) {
+                        if (preg_match('/<[\w]+[^>]*>/', $tag[0])) {
+                            array_unshift($openTags, $tag[2]);
+                        } elseif (preg_match('/<\/([\w]+)[^>]*>/', $tag[0], $closeTag)) {
+                            $pos = array_search($closeTag[1], $openTags);
+                            if ($pos !== false) {
+                                array_splice($openTags, $pos, 1);
                             }
                         }
                     }
 
-                    if (!$options['exact']) {
-                        $words = explode(' ', $tag[3]);
-                        // Keep at least one word.
-                        if (count($words) === 1) {
-                            $truncate .= mb_substr($tag[3], 0, $left + $entitiesLength);
-                        } else {
-                            $wordLength = 0;
-                            $addWords = [];
-                            // Append words until the length is crossed.
-                            foreach ($words as $word) {
-                                // Add words until we have enough letters.
-                                if ($wordLength < $left + $entitiesLength) {
-                                    $addWords[] = $word;
-                                }
-                                // Include inter-word space.
-                                $wordLength += mb_strlen($word) + 1;
-                            }
-                            $truncate .= implode(' ', $addWords);
-
-                            // If the string is longer than requested, find the last space and cut there.
-                            $lastSpace = mb_strrpos($truncate, ' ');
-                            if (mb_strlen($truncate) > $totalLength && $lastSpace !== false) {
-                                $remainder = mb_substr($truncate, $lastSpace);
-                                $truncate = mb_substr($truncate, 0, $lastSpace);
-
-                                // Re-add close tags that were cut off.
-                                preg_match_all('/<\/([a-z]+)>/', $remainder, $droppedTags, PREG_SET_ORDER);
-                                if ($droppedTags) {
-                                    foreach ($droppedTags as $closingTag) {
-                                        if (!in_array($closingTag[1], $openTags)) {
-                                            array_unshift($openTags, $closingTag[1]);
-                                        }
-                                    }
-                                }
-                            }
-                        }
+                    $prefix .= $tag[1];
+
+                    if ($totalLength + $contentLength + $ellipsisLength > $length) {
+                        $truncate = $tag[3];
+                        $truncateLength = $length - $totalLength;
                     } else {
-                        $truncate .= mb_substr($tag[3], 0, $left + $entitiesLength);
+                        $prefix .= $tag[3];
                     }
-                    break;
                 }
-                $truncate .= $tag[3];
 
                 $totalLength += $contentLength;
-                if ($totalLength >= $length) {
+                if ($totalLength > $length) {
                     break;
                 }
             }
 
-            $truncate .= $options['ellipsis'];
+            if ($totalLength <= $length) {
+                return $text;
+            }
+
+            $text = $truncate;
+            $length = $truncateLength;
 
             foreach ($openTags as $tag) {
-                $truncate .= '</' . $tag . '>';
+                $suffix .= '</' . $tag . '>';
             }
-            return $truncate;
+        } else {
+            if (self::_strlen($text, $options) <= $length) {
+                return $text;
+            }
+            $ellipsisLength = self::_strlen($options['ellipsis'], $options);
         }
 
-        if (mb_strlen($text) <= $length) {
-            return $text;
-        }
-        $truncate = mb_substr($text, 0, $length - mb_strlen($options['ellipsis']));
+        $result = self::_substr($text, 0, $length - $ellipsisLength, $options);
 
         if (!$options['exact']) {
-            $spacepos = mb_strrpos($truncate, ' ');
-            $truncate = mb_substr($truncate, 0, $spacepos);
+            if (self::_substr($text, $length - $ellipsisLength, 1, $options) !== ' ') {
+                $result = self::_removeLastWord($result);
+            }
+
+            // If result is empty, then we don't need to count ellipsis in the cut.
+            if (!strlen($result)) {
+                $result = self::_substr($text, 0, $length, $options);
+            }
+        }
+
+        return $prefix . $result . $suffix;
+    }
+
+    /**
+     * Truncate text with specified width.
+     *
+     * @param string $text String to truncate.
+     * @param int $length Length of returned string, including ellipsis.
+     * @param array $options An array of HTML attributes and options.
+     * @return string Trimmed string.
+     * @see \Cake\Utility\Text::truncate()
+     */
+    public static function truncateByWidth($text, $length = 100, array $options = [])
+    {
+        return static::truncate($text, $length, ['trimWidth' => true] + $options);
+    }
+
+    /**
+     * Get string length.
+     *
+     * ### Options:
+     *
+     * - `html` If true, HTML entities will be handled as decoded characters.
+     * - `trimWidth` If true, the width will return.
+     *
+     * @param string $text The string being checked for length
+     * @param array $options An array of options.
+     * @return string
+     */
+    protected static function _strlen($text, array $options)
+    {
+        if (empty($options['trimWidth'])) {
+            $strlen = 'mb_strlen';
+        } else {
+            $strlen = 'mb_strwidth';
+        }
+
+        if (empty($options['html'])) {
+            return $strlen($text);
+        }
 
-            // If truncate still empty, then we don't need to count ellipsis in the cut.
-            if (mb_strlen($truncate) === 0) {
-                $truncate = mb_substr($text, 0, $length);
+        $pattern = '/&[0-9a-z]{2,8};|&#[0-9]{1,7};|&#x[0-9a-f]{1,6};/i';
+        $replace = preg_replace_callback(
+            $pattern,
+            function ($match) use ($strlen) {
+                $utf8 = html_entity_decode($match[0], ENT_HTML5 | ENT_QUOTES, 'UTF-8');
+                return str_repeat(' ', $strlen($utf8, 'UTF-8'));
+            },
+            $text
+        );
+        return $strlen($replace);
+    }
+
+    /**
+     * Return part of a string.
+     *
+     * ### Options:
+     *
+     * - `html` If true, HTML entities will be handled as decoded characters.
+     * - `trimWidth` If true, will be truncated with specified width.
+     *
+     * @param string $text The input string.
+     * @param int $start The position to begin extracting.
+     * @param int $length The desired length.
+     * @param array $options An array of options.
+     * @return string
+     */
+    protected static function _substr($text, $start, $length, array $options)
+    {
+        if (empty($options['trimWidth'])) {
+            $substr = 'mb_substr';
+        } else {
+            $substr = 'mb_strimwidth';
+        }
+
+        $maxPosition = self::_strlen($text, ['trimWidth' => false] + $options);
+        if ($start < 0) {
+            $start += $maxPosition;
+            if ($start < 0) {
+                $start = 0;
+            }
+        }
+        if ($start >= $maxPosition) {
+            return '';
+        }
+
+        if ($length === null) {
+            $length = self::_strlen($text, $options);
+        }
+
+        if ($length < 0) {
+            $text = self::_substr($text, $start, null, $options);
+            $start = 0;
+            $length += self::_strlen($text, $options);
+        }
+
+        if ($length <= 0) {
+            return '';
+        }
+
+        if (empty($options['html'])) {
+            return (string)$substr($text, $start, $length);
+        }
+
+        $totalOffset = 0;
+        $totalLength = 0;
+        $result = '';
+
+        $pattern = '/(&[0-9a-z]{2,8};|&#[0-9]{1,7};|&#x[0-9a-f]{1,6};)/i';
+        $parts = preg_split($pattern, $text, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY);
+        foreach ($parts as $part) {
+            $offset = 0;
+
+            if ($totalOffset < $start) {
+                $len = self::_strlen($part, ['trimWidth' => false] + $options);
+                if ($totalOffset + $len <= $start) {
+                    $totalOffset += $len;
+                    continue;
+                }
+
+                $offset = $start - $totalOffset;
+                $totalOffset = $start;
+            }
+
+            $len = self::_strlen($part, $options);
+            if ($offset !== 0 || $totalLength + $len > $length) {
+                if (strpos($part, '&') === 0 && preg_match($pattern, $part)
+                    && $part !== html_entity_decode($part, ENT_HTML5 | ENT_QUOTES, 'UTF-8')
+                ) {
+                    // Entities cannot be passed substr.
+                    continue;
+                }
+
+                $part = $substr($part, $offset, $length - $totalLength);
+                $len = self::_strlen($part, $options);
+            }
+
+            $result .= $part;
+            $totalLength += $len;
+            if ($totalLength >= $length) {
+                break;
             }
         }
 
-        $truncate .= $options['ellipsis'];
-        return $truncate;
+        return $result;
+    }
+
+    /**
+     * Removes the last word from the input text.
+     *
+     * @param string $text The input text
+     * @return string
+     */
+    protected static function _removeLastWord($text)
+    {
+        $spacepos = mb_strrpos($text, ' ');
+
+        if ($spacepos !== false) {
+            $lastWord = mb_strrpos($text, $spacepos);
+
+            // Some languages are written without word separation.
+            // We recognize a string as a word if it doesn't contain any full-width characters.
+            if (mb_strwidth($lastWord) === mb_strlen($lastWord)) {
+                $text = mb_substr($text, 0, $spacepos);
+            }
+            return $text;
+        }
+
+        return '';
     }
 
     /**

+ 29 - 0
tests/TestCase/ORM/Behavior/TreeBehaviorTest.php

@@ -887,6 +887,35 @@ class TreeBehaviorTest extends TestCase
     }
 
     /**
+     * Tests making a node its own parent as an existing entity
+     *
+     * @expectedException RuntimeException
+     * @expectedExceptionMessage Cannot set a node's parent as itself
+     * @return void
+     */
+    public function testReParentSelf()
+    {
+        $entity = $this->table->get(1);
+        $entity->parent_id = $entity->id;
+        $this->table->save($entity);
+    }
+
+    /**
+     * Tests making a node its own parent as a new entity.
+     *
+     * @expectedException RuntimeException
+     * @expectedExceptionMessage Cannot set a node's parent as itself
+     * @return void
+     */
+    public function testReParentSelfNewEntity()
+    {
+        $entity = $this->table->newEntity(['name' => 'root']);
+        $entity->id = 1;
+        $entity->parent_id = $entity->id;
+        $this->table->save($entity);
+    }
+
+    /**
      * Tests moving a subtree to the right
      *
      * @return void

+ 23 - 1
tests/TestCase/TestSuite/IntegrationTestCaseTest.php

@@ -54,7 +54,11 @@ class IntegrationTestCaseTest extends IntegrationTestCase
     public function testRequestBuilding()
     {
         $this->configRequest([
-            'headers' => ['X-CSRF-Token' => 'abc123'],
+            'headers' => [
+                'X-CSRF-Token' => 'abc123',
+                'Content-Type' => 'application/json',
+                'Accept' => 'application/json'
+            ],
             'base' => '',
             'webroot' => '/',
             'environment' => [
@@ -67,6 +71,7 @@ class IntegrationTestCaseTest extends IntegrationTestCase
         $request = $this->_buildRequest('/tasks/add', 'POST', ['title' => 'First post']);
 
         $this->assertEquals('abc123', $request['environment']['HTTP_X_CSRF_TOKEN']);
+        $this->assertEquals('application/json', $request['environment']['CONTENT_TYPE']);
         $this->assertEquals('/tasks/add', $request['url']);
         $this->assertArrayHasKey('split_token', $request['cookies']);
         $this->assertEquals('def345', $request['cookies']['split_token']);
@@ -459,6 +464,23 @@ class IntegrationTestCaseTest extends IntegrationTestCase
     }
 
     /**
+     * Test posting to a secured form action.
+     *
+     * @return void
+     */
+    public function testPostSecuredFormWithQuery()
+    {
+        $this->enableSecurityToken();
+        $data = [
+            'title' => 'Some title',
+            'body' => 'Some text'
+        ];
+        $this->post('/posts/securePost?foo=bar', $data);
+        $this->assertResponseOk();
+        $this->assertResponseContains('Request was accepted');
+    }
+
+    /**
      * Test posting to a secured form action action.
      *
      * @return void

+ 162 - 29
tests/TestCase/Utility/TextTest.php

@@ -535,7 +535,7 @@ TEXT;
         $text1 = 'The quick brown fox jumps over the lazy dog';
         $text2 = 'Heiz&ouml;lr&uuml;cksto&szlig;abd&auml;mpfung';
         $text3 = '<b>&copy; 2005-2007, Cake Software Foundation, Inc.</b><br />written by Alexander Wegener';
-        $text4 = '<img src="mypic.jpg"> This image tag is not XHTML conform!<br><hr/><b>But the following image tag should be conform <img src="mypic.jpg" alt="Me, myself and I" /></b><br />Great, or?';
+        $text4 = '<IMG src="mypic.jpg"> This image tag is not XHTML conform!<br><hr/><b>But the following image tag should be conform <img src="mypic.jpg" alt="Me, myself and I" /></b><br />Great, or?';
         $text5 = '0<b>1<i>2<span class="myclass">3</span>4<u>5</u>6</i>7</b>8<b>9</b>0';
         $text6 = '<p><strong>Extra dates have been announced for this year\'s tour.</strong></p><p>Tickets for the new shows in</p>';
         $text7 = 'El moño está en el lugar correcto. Eso fue lo que dijo la niña, ¿habrá dicho la verdad?';
@@ -543,32 +543,34 @@ TEXT;
         $text9 = 'НОПРСТУФХЦЧШЩЪЫЬЭЮЯабвгдежзийклмнопрстуфхцчшщъыь';
         $text10 = 'http://example.com/something/foo:bar';
 
-        $this->assertSame($this->Text->truncate('Hello', 3), '...');
-        $this->assertSame($this->Text->truncate('Hello', 3, ['exact' => false]), 'Hel...');
-        $this->assertSame($this->Text->truncate($text1, 15), 'The quick br...');
-        $this->assertSame($this->Text->truncate($text1, 15, ['exact' => false]), 'The quick...');
-        $this->assertSame($this->Text->truncate($text1, 100), 'The quick brown fox jumps over the lazy dog');
-        $this->assertSame($this->Text->truncate($text2, 10), 'Heiz&ou...');
-        $this->assertSame($this->Text->truncate($text2, 10, ['exact' => false]), 'Heiz&ouml;...');
-        $this->assertSame($this->Text->truncate($text3, 20), '<b>&copy; 2005-20...');
-        $this->assertSame($this->Text->truncate($text4, 15), '<img src="my...');
-        $this->assertSame($this->Text->truncate($text5, 6, ['ellipsis' => '']), '0<b>1<');
-        $this->assertSame($this->Text->truncate($text1, 15, ['html' => true]), "The quick brow\xe2\x80\xa6");
-        $this->assertSame($this->Text->truncate($text1, 15, ['exact' => false, 'html' => true]), "The quick\xe2\x80\xa6");
-        $this->assertSame($this->Text->truncate($text2, 10, ['html' => true]), "Heiz&ouml;lr&uuml;c\xe2\x80\xa6");
-        $this->assertSame($this->Text->truncate($text2, 10, ['exact' => false, 'html' => true]), "Heiz&ouml;lr&uuml;c\xe2\x80\xa6");
-        $this->assertSame($this->Text->truncate($text3, 20, ['html' => true]), "<b>&copy; 2005-2007, Cake S\xe2\x80\xa6</b>");
-        $this->assertSame($this->Text->truncate($text4, 15, ['html' => true]), "<img src=\"mypic.jpg\"> This image ta\xe2\x80\xa6");
-        $this->assertSame($this->Text->truncate($text4, 45, ['html' => true]), "<img src=\"mypic.jpg\"> This image tag is not XHTML conform!<br><hr/><b>But the\xe2\x80\xa6</b>");
-        $this->assertSame($this->Text->truncate($text4, 90, ['html' => true]), '<img src="mypic.jpg"> This image tag is not XHTML conform!<br><hr/><b>But the following image tag should be conform <img src="mypic.jpg" alt="Me, myself and I" /></b><br />Great,' . "\xe2\x80\xa6");
-        $this->assertSame($this->Text->truncate($text5, 6, ['ellipsis' => '', 'html' => true]), '0<b>1<i>2<span class="myclass">3</span>4<u>5</u></i></b>');
-        $this->assertSame($this->Text->truncate($text5, 20, ['ellipsis' => '', 'html' => true]), $text5);
-        $this->assertSame($this->Text->truncate($text6, 57, ['exact' => false, 'html' => true]), "<p><strong>Extra dates have been announced for this year's\xe2\x80\xa6</strong></p>");
-        $this->assertSame($this->Text->truncate($text7, 255), $text7);
-        $this->assertSame($this->Text->truncate($text7, 15), 'El moño está...');
-        $this->assertSame($this->Text->truncate($text8, 15), 'Vive la R' . chr(195) . chr(169) . 'pu...');
-        $this->assertSame($this->Text->truncate($text9, 10), 'НОПРСТУ...');
-        $this->assertSame($this->Text->truncate($text10, 30), 'http://example.com/somethin...');
+        $this->assertSame('...', $this->Text->truncate('Hello', 3));
+        $this->assertSame('Hel...', $this->Text->truncate('Hello', 3, ['exact' => false]));
+        $this->assertSame('The quick br...', $this->Text->truncate($text1, 15));
+        $this->assertSame('The quick...', $this->Text->truncate($text1, 15, ['exact' => false]));
+        $this->assertSame('The quick brown fox jumps over the lazy dog', $this->Text->truncate($text1, 100));
+        $this->assertSame('Heiz&ou...', $this->Text->truncate($text2, 10));
+        $this->assertSame('Heiz&ouml;...', $this->Text->truncate($text2, 10, ['exact' => false]));
+        $this->assertSame('<b>&copy; 2005-20...', $this->Text->truncate($text3, 20));
+        $this->assertSame('<IMG src="my...', $this->Text->truncate($text4, 15));
+        $this->assertSame('0<b>1<', $this->Text->truncate($text5, 6, ['ellipsis' => '']));
+        $this->assertSame("The quick brow\xe2\x80\xa6", $this->Text->truncate($text1, 15, ['html' => true]));
+        $this->assertSame("The quick\xe2\x80\xa6", $this->Text->truncate($text1, 15, ['exact' => false, 'html' => true]));
+        $this->assertSame("Heiz&ouml;lr&uuml;c\xe2\x80\xa6", $this->Text->truncate($text2, 10, ['html' => true]));
+        $this->assertSame("Heiz&ouml;lr&uuml;ck\xe2\x80\xa6", $this->Text->truncate($text2, 10, ['exact' => false, 'html' => true]));
+        $this->assertSame("<b>&copy; 2005-2007, Cake S\xe2\x80\xa6</b>", $this->Text->truncate($text3, 20, ['html' => true]));
+        $this->assertSame("<IMG src=\"mypic.jpg\"> This image ta\xe2\x80\xa6", $this->Text->truncate($text4, 15, ['html' => true]));
+        $this->assertSame("<IMG src=\"mypic.jpg\"> This image tag is not XHTML conform!<br><hr/><b>But the\xe2\x80\xa6</b>", $this->Text->truncate($text4, 45, ['html' => true]));
+        $this->assertSame('<IMG src="mypic.jpg"> This image tag is not XHTML conform!<br><hr/><b>But the following image tag should be conform <img src="mypic.jpg" alt="Me, myself and I" /></b><br />Great,' . "\xe2\x80\xa6", $this->Text->truncate($text4, 90, ['html' => true]));
+        $this->assertSame('0<b>1<i>2<span class="myclass">3</span>4<u>5</u></i></b>', $this->Text->truncate($text5, 6, ['ellipsis' => '', 'html' => true]));
+        $this->assertSame($text5, $this->Text->truncate($text5, 20, ['ellipsis' => '', 'html' => true]));
+        $this->assertSame("<p><strong>Extra dates have been announced for this year's\xe2\x80\xa6</strong></p>", $this->Text->truncate($text6, 48, ['exact' => false, 'html' => true]));
+        $this->assertSame($text7, $this->Text->truncate($text7, 255));
+        $this->assertSame('El moño está...', $this->Text->truncate($text7, 15));
+        $this->assertSame('Vive la R' . chr(195) . chr(169) . 'pu...', $this->Text->truncate($text8, 15));
+        $this->assertSame('НОПРСТУ...', $this->Text->truncate($text9, 10));
+        $this->assertSame('http://example.com/somethin...', $this->Text->truncate($text10, 30));
+        $this->assertSame('1 <b>2...</b>', $this->Text->truncate('1 <b>2 345</b>', 6, ['exact' => false, 'html' => true, 'ellipsis' => '...']));
+        $this->assertSame('&amp;', $this->Text->truncate('&amp;', 1, ['html' => true]));
 
         $text = '<p><span style="font-size: medium;"><a>Iamatestwithnospacesandhtml</a></span></p>';
         $result = $this->Text->truncate($text, 10, [
@@ -576,7 +578,7 @@ TEXT;
             'exact' => false,
             'html' => true
         ]);
-        $expected = '<p><span style="font-size: medium;"><a>Iamates...</a></span></p>';
+        $expected = '<p><span style="font-size: medium;"><a>Iamatestwi...</a></span></p>';
         $this->assertEquals($expected, $result);
     }
 
@@ -595,7 +597,7 @@ TEXT;
         ]);
         $this->assertEquals($expected, $result);
 
-        $expected = '<a href="http://example.org">hell..</a>';
+        $expected = '<a href="http://example.org">hello..</a>';
         $result = Text::truncate($text, 6, [
             'ellipsis' => '..',
             'exact' => false,
@@ -629,6 +631,39 @@ TEXT;
     }
 
     /**
+     * Test truncate() method with trimWidth
+     *
+     * @return void
+     */
+    public function testTruncateTrimWidth()
+    {
+        $text = 'The quick brown fox jumps over the lazy dog';
+        $this->assertEquals('The quick brown...', Text::truncate($text, 18, ['ellipsis' => '...', 'trimWidth' => false]));
+        $this->assertEquals('The quick brown...', Text::truncate($text, 18, ['ellipsis' => '...', 'trimWidth' => true]));
+
+        $text = 'はしこい茶色の狐はのろまな犬を飛び越える';
+        $this->assertEquals('はしこい茶色の狐はのろまな犬を...', Text::truncate($text, 18, ['ellipsis' => '...', 'trimWidth' => false]));
+        $this->assertEquals('はしこい茶色の...', Text::truncate($text, 18, ['ellipsis' => '...', 'trimWidth' => true]));
+
+        $text = 'はしこい茶色の狐 - The quick brown fox';
+        $this->assertEquals('はしこい茶色の狐 - The quick bro...', Text::truncate($text, 27, ['ellipsis' => '...', 'trimWidth' => false]));
+        $this->assertEquals('はしこい茶色の狐 - The q...', Text::truncate($text, 27, ['ellipsis' => '...', 'trimWidth' => true]));
+        $this->assertEquals('はしこい茶色の狐 - The...', Text::truncate($text, 27, ['ellipsis' => '...', 'trimWidth' => true, 'exact' => false]));
+
+        $text = '<p>はしこい<font color="brown">茶色</font>の狐はのろまな犬を飛び越える</p>';
+        $this->assertEquals('<p>はしこい<font color="brown">茶色</font>の狐はのろまな犬を...</p>', Text::truncate($text, 18, ['ellipsis' => '...', 'trimWidth' => false, 'html' => true]));
+        $this->assertEquals('<p>はしこい<font color="brown">茶色</font>の...</p>', Text::truncate($text, 18, ['ellipsis' => '...', 'trimWidth' => true, 'html' => true]));
+
+        $text = <<<HTML
+<IMG src="mypic.jpg">このimageタグはXHTMLに準拠していない!<br>
+<hr/><b>でも次のimageタグは準拠しているはず <img src="mypic.jpg" alt="私の、私自身そして私" /></b><br />
+素晴らしい、でしょ?
+HTML;
+        $this->assertEquals("<IMG src=\"mypic.jpg\">このimageタグはXHTMLに準拠していない!<br>\n<hr/><b>でも次の…</b>", Text::truncate($text, 30, ['html' => true]));
+        $this->assertEquals("<IMG src=\"mypic.jpg\">このimageタグはXHTMLに準拠し…", Text::truncate($text, 30, ['html' => true, 'trimWidth' => true]));
+    }
+
+    /**
      * testTail method
      *
      * @return void
@@ -1748,4 +1783,102 @@ TEXT;
         $result = Text::slug($string, $options);
         $this->assertEquals($expected, $result);
     }
+
+    /**
+     * Text truncateByWidth method
+     *
+     * @return void
+     */
+    public function testTruncateByWidth()
+    {
+        $this->assertSame('<p>あ...', Text::truncateByWidth('<p>あいうえお</p>', 8));
+        $this->assertSame('<p>あい...</p>', Text::truncateByWidth('<p>あいうえお</p>', 8, ['html' => true, 'ellipsis' => '...']));
+    }
+
+    /**
+     * Test _strlen method
+     *
+     * @return void
+     */
+    public function testStrlen()
+    {
+        $method = new \ReflectionMethod('Cake\Utility\Text', '_strlen');
+        $method->setAccessible(true);
+        $strlen = function () use ($method) {
+            return $method->invokeArgs(null, func_get_args());
+        };
+
+        $text = 'データベースアクセス &amp; ORM';
+        $this->assertEquals(20, $strlen($text, []));
+        $this->assertEquals(16, $strlen($text, ['html' => true]));
+        $this->assertEquals(30, $strlen($text, ['trimWidth' => true]));
+        $this->assertEquals(26, $strlen($text, ['html' => true, 'trimWidth' => true]));
+
+        $text = '&undefined;';
+        $this->assertEquals(11, $strlen($text, []));
+        $this->assertEquals(11, $strlen($text, ['trimWidth' => true]));
+        $this->assertEquals(11, $strlen($text, ['html' => true]));
+        $this->assertEquals(11, $strlen($text, ['html' => true, 'trimWidth' => true]));
+    }
+
+    /**
+     * Test _substr method
+     *
+     * @return void
+     */
+    public function testSubstr()
+    {
+        $method = new \ReflectionMethod('Cake\Utility\Text', '_substr');
+        $method->setAccessible(true);
+        $substr = function () use ($method) {
+            return $method->invokeArgs(null, func_get_args());
+        };
+
+        $text = 'データベースアクセス &amp; ORM';
+        $this->assertEquals('アクセス', $substr($text, 6, 4, []));
+        $this->assertEquals('アクセス', $substr($text, 6, 8, ['trimWidth' => true]));
+        $this->assertEquals('アクセス', $substr($text, 6, 4, ['html' => true]));
+        $this->assertEquals(' &amp; ', $substr($text, 10, 7, []));
+        $this->assertEquals(' &amp; ', $substr($text, 10, 7, ['trimWidth' => true]));
+        $this->assertEquals(' &amp; ', $substr($text, 10, 3, ['html' => true]));
+        $this->assertEquals(' &amp; ', $substr($text, -10, 7, []));
+        $this->assertEquals(' &amp; ', $substr($text, -10, 7, ['trimWidth' => true]));
+        $this->assertEquals(' &amp; ', $substr($text, -6, 3, ['html' => true]));
+        $this->assertEquals(' &amp; ', $substr($text, -10, -3, []));
+        $this->assertEquals(' &amp; ', $substr($text, -10, -3, ['trimWidth' => true]));
+        $this->assertEquals(' &amp; ', $substr($text, -6, -3, ['html' => true]));
+        $this->assertEquals('ORM', $substr($text, -3, 1000, []));
+        $this->assertEquals('ORM', $substr($text, -3, 1000, ['trimWidth' => true]));
+        $this->assertEquals('ORM', $substr($text, -3, 1000, ['html' => true]));
+        $this->assertEquals('ORM', $substr($text, -3, null, []));
+        $this->assertEquals('ORM', $substr($text, -3, null, ['trimWidth' => true]));
+        $this->assertEquals('ORM', $substr($text, -3, null, ['html' => true]));
+        $this->assertEquals('データ', $substr($text, -1000, 3, []));
+        $this->assertEquals('データ', $substr($text, -1000, 6, ['trimWidth' => true]));
+        $this->assertEquals('データ', $substr($text, -1000, 3, ['html' => true]));
+        $this->assertEquals('', $substr($text, 0, 0, []));
+        $this->assertEquals('', $substr($text, 0, 0, ['trimWidth' => true]));
+        $this->assertEquals('', $substr($text, 0, 0, ['html' => true]));
+        $this->assertEquals('', $substr($text, 1000, 1, []));
+        $this->assertEquals('', $substr($text, 1000, 1, ['trimWidth' => true]));
+        $this->assertEquals('', $substr($text, 1000, 1, ['html' => true]));
+        $this->assertEquals('', $substr($text, 0, -1000, []));
+        $this->assertEquals('', $substr($text, 0, -1000, ['trimWidth' => true]));
+        $this->assertEquals('', $substr($text, 0, -1000, ['html' => true]));
+
+        // ABCDE
+        $text = '&#65;&#66;&#67;&#68;&#69;';
+        $this->assertEquals('&#66;&#67;&#68;', $substr($text, 1, 3, ['html' => true]));
+        $this->assertEquals('&#66;&#67;&#68;', $substr($text, 1, 3, ['html' => true, 'trimWidth' => true]));
+        $this->assertEquals('&#66;&#67;&#68;', $substr($text, -4, -1, ['html' => true]));
+        $this->assertEquals('&#66;&#67;&#68;', $substr($text, -4, -1, ['html' => true, 'trimWidth' => true]));
+
+        // あいうえお
+        $text = '&#x3042;&#x3044;&#x3046;&#x3048;&#x304a;';
+        $this->assertEquals('&#x3044;&#x3046;&#x3048;', $substr($text, 1, 3, ['html' => true]));
+        $this->assertEquals('&#x3044;&#x3046;&#x3048;', $substr($text, 1, 6, ['html' => true, 'trimWidth' => true]));
+        $this->assertEquals('&#x3044;&#x3046;&#x3048;', $substr($text, -4, -1, ['html' => true]));
+        $this->assertEquals('&#x3044;&#x3046;&#x3048;', $substr($text, -4, -1, ['html' => true, 'trimWidth' => true]));
+        $this->assertEquals('&#x3044;&#x3046;&#x3048;', $substr($text, -4, -2, ['html' => true, 'trimWidth' => true]));
+    }
 }