@recaptime-dev's working patches + fork for Phorge, a community fork of Phabricator. (Upstream dev and stable branches are at upstream/main and upstream/stable respectively.) hq.recaptime.dev/wiki/Phorge
phorge phabricator
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add "PhutilProseDiff" classes to "phabricator/"

Summary: Depends on D20836. Ref T13414. Ref T13425. Ref T13395. Move these to "phabricator/" before trying to improve the high-level diff engine in prose diffs.

Test Plan: Ran "arc liberate", looked at a prose diff (no behavioral change).

Maniphest Tasks: T13425, T13414, T13395

Differential Revision: https://secure.phabricator.com/D20838

+752
+6
src/__phutil_library_map__.php
··· 5599 5599 'PhutilOAuthAuthAdapter' => 'applications/auth/adapter/PhutilOAuthAuthAdapter.php', 5600 5600 'PhutilPHPCodeSnippetContextFreeGrammar' => 'infrastructure/lipsum/code/PhutilPHPCodeSnippetContextFreeGrammar.php', 5601 5601 'PhutilPhabricatorAuthAdapter' => 'applications/auth/adapter/PhutilPhabricatorAuthAdapter.php', 5602 + 'PhutilProseDiff' => 'infrastructure/diff/prose/PhutilProseDiff.php', 5603 + 'PhutilProseDiffTestCase' => 'infrastructure/diff/prose/__tests__/PhutilProseDiffTestCase.php', 5604 + 'PhutilProseDifferenceEngine' => 'infrastructure/diff/prose/PhutilProseDifferenceEngine.php', 5602 5605 'PhutilQsprintfInterface' => 'infrastructure/storage/xsprintf/PhutilQsprintfInterface.php', 5603 5606 'PhutilQueryString' => 'infrastructure/storage/xsprintf/PhutilQueryString.php', 5604 5607 'PhutilRealNameContextFreeGrammar' => 'infrastructure/lipsum/PhutilRealNameContextFreeGrammar.php', ··· 12398 12401 'PhutilOAuthAuthAdapter' => 'PhutilAuthAdapter', 12399 12402 'PhutilPHPCodeSnippetContextFreeGrammar' => 'PhutilCLikeCodeSnippetContextFreeGrammar', 12400 12403 'PhutilPhabricatorAuthAdapter' => 'PhutilOAuthAuthAdapter', 12404 + 'PhutilProseDiff' => 'Phobject', 12405 + 'PhutilProseDiffTestCase' => 'PhutilTestCase', 12406 + 'PhutilProseDifferenceEngine' => 'Phobject', 12401 12407 'PhutilQueryString' => 'Phobject', 12402 12408 'PhutilRealNameContextFreeGrammar' => 'PhutilContextFreeGrammar', 12403 12409 'PhutilRemarkupAnchorRule' => 'PhutilRemarkupRule',
+292
src/infrastructure/diff/prose/PhutilProseDiff.php
··· 1 + <?php 2 + 3 + final class PhutilProseDiff extends Phobject { 4 + 5 + private $parts = array(); 6 + 7 + public function addPart($type, $text) { 8 + $this->parts[] = array( 9 + 'type' => $type, 10 + 'text' => $text, 11 + ); 12 + return $this; 13 + } 14 + 15 + public function getParts() { 16 + return $this->parts; 17 + } 18 + 19 + /** 20 + * Get diff parts, but replace large blocks of unchanged text with "." 21 + * parts representing missing context. 22 + */ 23 + public function getSummaryParts() { 24 + $parts = $this->getParts(); 25 + 26 + $head_key = head_key($parts); 27 + $last_key = last_key($parts); 28 + 29 + $results = array(); 30 + foreach ($parts as $key => $part) { 31 + $is_head = ($key == $head_key); 32 + $is_last = ($key == $last_key); 33 + 34 + switch ($part['type']) { 35 + case '=': 36 + $pieces = $this->splitTextForSummary($part['text']); 37 + 38 + if ($is_head || $is_last) { 39 + $need = 2; 40 + } else { 41 + $need = 3; 42 + } 43 + 44 + // We don't have enough pieces to omit anything, so just continue. 45 + if (count($pieces) < $need) { 46 + $results[] = $part; 47 + break; 48 + } 49 + 50 + if (!$is_head) { 51 + $results[] = array( 52 + 'type' => '=', 53 + 'text' => head($pieces), 54 + ); 55 + } 56 + 57 + $results[] = array( 58 + 'type' => '.', 59 + 'text' => null, 60 + ); 61 + 62 + if (!$is_last) { 63 + $results[] = array( 64 + 'type' => '=', 65 + 'text' => last($pieces), 66 + ); 67 + } 68 + break; 69 + default: 70 + $results[] = $part; 71 + break; 72 + } 73 + } 74 + 75 + return $results; 76 + } 77 + 78 + 79 + public function reorderParts() { 80 + // Reorder sequences of removed and added sections to put all the "-" 81 + // parts together first, then all the "+" parts together. This produces 82 + // a more human-readable result than intermingling them. 83 + 84 + $o_run = array(); 85 + $n_run = array(); 86 + $result = array(); 87 + foreach ($this->parts as $part) { 88 + $type = $part['type']; 89 + switch ($type) { 90 + case '-': 91 + $o_run[] = $part; 92 + break; 93 + case '+': 94 + $n_run[] = $part; 95 + break; 96 + default: 97 + if ($o_run || $n_run) { 98 + foreach ($this->combineRuns($o_run, $n_run) as $merged_part) { 99 + $result[] = $merged_part; 100 + } 101 + $o_run = array(); 102 + $n_run = array(); 103 + } 104 + $result[] = $part; 105 + break; 106 + } 107 + } 108 + 109 + if ($o_run || $n_run) { 110 + foreach ($this->combineRuns($o_run, $n_run) as $part) { 111 + $result[] = $part; 112 + } 113 + } 114 + 115 + // Now, combine consecuitive runs of the same type of change (like a 116 + // series of "-" parts) into a single run. 117 + $combined = array(); 118 + 119 + $last = null; 120 + $last_text = null; 121 + foreach ($result as $part) { 122 + $type = $part['type']; 123 + 124 + if ($last !== $type) { 125 + if ($last !== null) { 126 + $combined[] = array( 127 + 'type' => $last, 128 + 'text' => $last_text, 129 + ); 130 + } 131 + $last_text = null; 132 + $last = $type; 133 + } 134 + 135 + $last_text .= $part['text']; 136 + } 137 + 138 + if ($last_text !== null) { 139 + $combined[] = array( 140 + 'type' => $last, 141 + 'text' => $last_text, 142 + ); 143 + } 144 + 145 + $this->parts = $combined; 146 + 147 + return $this; 148 + } 149 + 150 + private function combineRuns($o_run, $n_run) { 151 + $o_merge = $this->mergeParts($o_run); 152 + $n_merge = $this->mergeParts($n_run); 153 + 154 + // When removed and added blocks share a prefix or suffix, we sometimes 155 + // want to count it as unchanged (for example, if it is whitespace) but 156 + // sometimes want to count it as changed (for example, if it is a word 157 + // suffix like "ing"). Find common prefixes and suffixes of these layout 158 + // characters and emit them as "=" (unchanged) blocks. 159 + 160 + $layout_characters = array( 161 + ' ' => true, 162 + "\n" => true, 163 + '.' => true, 164 + '!' => true, 165 + ',' => true, 166 + '?' => true, 167 + ']' => true, 168 + '[' => true, 169 + '(' => true, 170 + ')' => true, 171 + '<' => true, 172 + '>' => true, 173 + ); 174 + 175 + $o_text = $o_merge['text']; 176 + $n_text = $n_merge['text']; 177 + $o_len = strlen($o_text); 178 + $n_len = strlen($n_text); 179 + $min_len = min($o_len, $n_len); 180 + 181 + $prefix_len = 0; 182 + for ($pos = 0; $pos < $min_len; $pos++) { 183 + $o = $o_text[$pos]; 184 + $n = $n_text[$pos]; 185 + if ($o !== $n) { 186 + break; 187 + } 188 + if (empty($layout_characters[$o])) { 189 + break; 190 + } 191 + $prefix_len++; 192 + } 193 + 194 + $suffix_len = 0; 195 + for ($pos = 0; $pos < ($min_len - $prefix_len); $pos++) { 196 + $o = $o_text[$o_len - ($pos + 1)]; 197 + $n = $n_text[$n_len - ($pos + 1)]; 198 + if ($o !== $n) { 199 + break; 200 + } 201 + if (empty($layout_characters[$o])) { 202 + break; 203 + } 204 + $suffix_len++; 205 + } 206 + 207 + $results = array(); 208 + 209 + if ($prefix_len) { 210 + $results[] = array( 211 + 'type' => '=', 212 + 'text' => substr($o_text, 0, $prefix_len), 213 + ); 214 + } 215 + 216 + if ($prefix_len < $o_len) { 217 + $results[] = array( 218 + 'type' => '-', 219 + 'text' => substr( 220 + $o_text, 221 + $prefix_len, 222 + $o_len - $prefix_len - $suffix_len), 223 + ); 224 + } 225 + 226 + if ($prefix_len < $n_len) { 227 + $results[] = array( 228 + 'type' => '+', 229 + 'text' => substr( 230 + $n_text, 231 + $prefix_len, 232 + $n_len - $prefix_len - $suffix_len), 233 + ); 234 + } 235 + 236 + if ($suffix_len) { 237 + $results[] = array( 238 + 'type' => '=', 239 + 'text' => substr($o_text, -$suffix_len), 240 + ); 241 + } 242 + 243 + return $results; 244 + } 245 + 246 + private function mergeParts(array $parts) { 247 + $text = ''; 248 + $type = null; 249 + foreach ($parts as $part) { 250 + $part_type = $part['type']; 251 + if ($type === null) { 252 + $type = $part_type; 253 + } 254 + if ($type !== $part_type) { 255 + throw new Exception(pht('Can not merge parts of dissimilar types!')); 256 + } 257 + $text .= $part['text']; 258 + } 259 + 260 + return array( 261 + 'type' => $type, 262 + 'text' => $text, 263 + ); 264 + } 265 + 266 + private function splitTextForSummary($text) { 267 + $matches = null; 268 + 269 + $ok = preg_match('/^(\n*[^\n]+)\n/', $text, $matches); 270 + if (!$ok) { 271 + return array($text); 272 + } 273 + 274 + $head = $matches[1]; 275 + $text = substr($text, strlen($head)); 276 + 277 + $ok = preg_match('/\n([^\n]+\n*)\z/', $text, $matches); 278 + if (!$ok) { 279 + return array($text); 280 + } 281 + 282 + $last = $matches[1]; 283 + $text = substr($text, 0, -strlen($last)); 284 + 285 + if (!strlen(trim($text))) { 286 + return array($head, $last); 287 + } else { 288 + return array($head, $text, $last); 289 + } 290 + } 291 + 292 + }
+209
src/infrastructure/diff/prose/PhutilProseDifferenceEngine.php
··· 1 + <?php 2 + 3 + final class PhutilProseDifferenceEngine extends Phobject { 4 + 5 + public function getDiff($u, $v) { 6 + return $this->buildDiff($u, $v, 0); 7 + } 8 + 9 + private function buildDiff($u, $v, $level) { 10 + $u_parts = $this->splitCorpus($u, $level); 11 + $v_parts = $this->splitCorpus($v, $level); 12 + 13 + $matrix = id(new PhutilEditDistanceMatrix()) 14 + ->setMaximumLength(128) 15 + ->setSequences($u_parts, $v_parts) 16 + ->setComputeString(true); 17 + 18 + // For word-level and character-level changes, smooth the output string 19 + // to reduce the choppiness of the diff. 20 + if ($level > 1) { 21 + $matrix->setApplySmoothing(PhutilEditDistanceMatrix::SMOOTHING_FULL); 22 + } 23 + 24 + $u_pos = 0; 25 + $v_pos = 0; 26 + 27 + $edits = $matrix->getEditString(); 28 + $edits_length = strlen($edits); 29 + 30 + $diff = new PhutilProseDiff(); 31 + for ($ii = 0; $ii < $edits_length; $ii++) { 32 + $c = $edits[$ii]; 33 + if ($c == 's') { 34 + $diff->addPart('=', $u_parts[$u_pos]); 35 + $u_pos++; 36 + $v_pos++; 37 + } else if ($c == 'd') { 38 + $diff->addPart('-', $u_parts[$u_pos]); 39 + $u_pos++; 40 + } else if ($c == 'i') { 41 + $diff->addPart('+', $v_parts[$v_pos]); 42 + $v_pos++; 43 + } else if ($c == 'x') { 44 + $diff->addPart('-', $u_parts[$u_pos]); 45 + $diff->addPart('+', $v_parts[$v_pos]); 46 + $u_pos++; 47 + $v_pos++; 48 + } else { 49 + throw new Exception( 50 + pht( 51 + 'Unexpected character ("%s") in edit string.', 52 + $c)); 53 + } 54 + } 55 + 56 + $diff->reorderParts(); 57 + 58 + // If we just built a character-level diff, we're all done and do not 59 + // need to go any deeper. 60 + if ($level == 3) { 61 + return $diff; 62 + } 63 + 64 + $blocks = array(); 65 + $block = null; 66 + foreach ($diff->getParts() as $part) { 67 + $type = $part['type']; 68 + $text = $part['text']; 69 + switch ($type) { 70 + case '=': 71 + if ($block) { 72 + $blocks[] = $block; 73 + $block = null; 74 + } 75 + $blocks[] = array( 76 + 'type' => $type, 77 + 'text' => $text, 78 + ); 79 + break; 80 + case '-': 81 + if (!$block) { 82 + $block = array( 83 + 'type' => '!', 84 + 'old' => '', 85 + 'new' => '', 86 + ); 87 + } 88 + $block['old'] .= $text; 89 + break; 90 + case '+': 91 + if (!$block) { 92 + $block = array( 93 + 'type' => '!', 94 + 'old' => '', 95 + 'new' => '', 96 + ); 97 + } 98 + $block['new'] .= $text; 99 + break; 100 + } 101 + } 102 + 103 + if ($block) { 104 + $blocks[] = $block; 105 + } 106 + 107 + $result = new PhutilProseDiff(); 108 + foreach ($blocks as $block) { 109 + $type = $block['type']; 110 + if ($type == '=') { 111 + $result->addPart('=', $block['text']); 112 + } else { 113 + $old = $block['old']; 114 + $new = $block['new']; 115 + if (!strlen($old) && !strlen($new)) { 116 + // Nothing to do. 117 + } else if (!strlen($old)) { 118 + $result->addPart('+', $new); 119 + } else if (!strlen($new)) { 120 + $result->addPart('-', $old); 121 + } else { 122 + if ($matrix->didReachMaximumLength()) { 123 + // If this text was too big to diff, don't try to subdivide it. 124 + $result->addPart('-', $old); 125 + $result->addPart('+', $new); 126 + } else { 127 + $subdiff = $this->buildDiff( 128 + $old, 129 + $new, 130 + $level + 1); 131 + 132 + foreach ($subdiff->getParts() as $part) { 133 + $result->addPart($part['type'], $part['text']); 134 + } 135 + } 136 + } 137 + } 138 + } 139 + 140 + $result->reorderParts(); 141 + 142 + return $result; 143 + } 144 + 145 + private function splitCorpus($corpus, $level) { 146 + switch ($level) { 147 + case 0: 148 + // Level 0: Split into paragraphs. 149 + $expr = '/([\n]+)/'; 150 + break; 151 + case 1: 152 + // Level 1: Split into sentences. 153 + $expr = '/([\n,!;?\.]+)/'; 154 + break; 155 + case 2: 156 + // Level 2: Split into words. 157 + $expr = '/(\s+)/'; 158 + break; 159 + case 3: 160 + // Level 3: Split into characters. 161 + return phutil_utf8v_combined($corpus); 162 + } 163 + 164 + $pieces = preg_split($expr, $corpus, -1, PREG_SPLIT_DELIM_CAPTURE); 165 + return $this->stitchPieces($pieces, $level); 166 + } 167 + 168 + private function stitchPieces(array $pieces, $level) { 169 + $results = array(); 170 + $count = count($pieces); 171 + for ($ii = 0; $ii < $count; $ii += 2) { 172 + $result = $pieces[$ii]; 173 + if ($ii + 1 < $count) { 174 + $result .= $pieces[$ii + 1]; 175 + } 176 + 177 + if ($level < 2) { 178 + // Split pieces into separate text and whitespace sections: make one 179 + // piece out of all the whitespace at the beginning, one piece out of 180 + // all the actual text in the middle, and one piece out of all the 181 + // whitespace at the end. 182 + 183 + $matches = null; 184 + preg_match('/^(\s*)(.*?)(\s*)\z/', $result, $matches); 185 + 186 + if (strlen($matches[1])) { 187 + $results[] = $matches[1]; 188 + } 189 + if (strlen($matches[2])) { 190 + $results[] = $matches[2]; 191 + } 192 + if (strlen($matches[3])) { 193 + $results[] = $matches[3]; 194 + } 195 + } else { 196 + $results[] = $result; 197 + } 198 + } 199 + 200 + // If the input ended with a delimiter, we can get an empty final piece. 201 + // Just discard it. 202 + if (last($results) == '') { 203 + array_pop($results); 204 + } 205 + 206 + return $results; 207 + } 208 + 209 + }
+245
src/infrastructure/diff/prose/__tests__/PhutilProseDiffTestCase.php
··· 1 + <?php 2 + 3 + final class PhutilProseDiffTestCase extends PhutilTestCase { 4 + 5 + public function testProseDiffsDistance() { 6 + $this->assertProseParts( 7 + '', 8 + '', 9 + array(), 10 + pht('Empty')); 11 + 12 + $this->assertProseParts( 13 + "xxx\nyyy", 14 + "xxx\nzzz\nyyy", 15 + array( 16 + "= xxx\n", 17 + "+ zzz\n", 18 + '= yyy', 19 + ), 20 + pht('Add Paragraph')); 21 + 22 + $this->assertProseParts( 23 + "xxx\nzzz\nyyy", 24 + "xxx\nyyy", 25 + array( 26 + "= xxx\n", 27 + "- zzz\n", 28 + '= yyy', 29 + ), 30 + pht('Remove Paragraph')); 31 + 32 + 33 + // Without smoothing, the alogorithm identifies that "shark" and "cat" 34 + // both contain the letter "a" and tries to express this as a very 35 + // fine-grained edit which replaces "sh" with "c" and then "rk" with "t". 36 + // This is technically correct, but it is much easier for human viewers to 37 + // parse if we smooth this into a single removal and a single addition. 38 + 39 + $this->assertProseParts( 40 + 'They say the shark has nine lives.', 41 + 'They say the cat has nine lives.', 42 + array( 43 + '= They say the ', 44 + '- shark', 45 + '+ cat', 46 + '= has nine lives.', 47 + ), 48 + pht('"Shark/cat" word edit smoothenss.')); 49 + 50 + $this->assertProseParts( 51 + 'Rising quickly, she says', 52 + 'Rising quickly, she remarks:', 53 + array( 54 + '= Rising quickly, she ', 55 + '- says', 56 + '+ remarks:', 57 + ), 58 + pht('"Says/remarks" word edit smoothenss.')); 59 + 60 + $this->assertProseParts( 61 + 'See screenshots', 62 + 'Viewed video files', 63 + array( 64 + '- See screenshots', 65 + '+ Viewed video files', 66 + ), 67 + pht('Complete paragraph rewrite.')); 68 + 69 + $this->assertProseParts( 70 + 'xaaax', 71 + 'xbbbx', 72 + array( 73 + '- xaaax', 74 + '+ xbbbx', 75 + ), 76 + pht('Whole word rewrite with common prefix and suffix.')); 77 + 78 + $this->assertProseParts( 79 + ' aaa ', 80 + ' bbb ', 81 + array( 82 + '= ', 83 + '- aaa', 84 + '+ bbb', 85 + '= ', 86 + ), 87 + pht('Whole word rewrite with whitespace prefix and suffix.')); 88 + 89 + $this->assertSummaryProseParts( 90 + "a\nb\nc\nd\ne\nf\ng\nh\n", 91 + "a\nb\nc\nd\nX\nf\ng\nh\n", 92 + array( 93 + '.', 94 + "= d\n", 95 + '- e', 96 + '+ X', 97 + "= \nf", 98 + '.', 99 + ), 100 + pht('Summary diff with middle change.')); 101 + 102 + $this->assertSummaryProseParts( 103 + "a\nb\nc\nd\ne\nf\ng\nh\n", 104 + "X\nb\nc\nd\ne\nf\ng\nh\n", 105 + array( 106 + '- a', 107 + '+ X', 108 + "= \nb", 109 + '.', 110 + ), 111 + pht('Summary diff with head change.')); 112 + 113 + $this->assertSummaryProseParts( 114 + "a\nb\nc\nd\ne\nf\ng\nh\n", 115 + "a\nb\nc\nd\ne\nf\ng\nX\n", 116 + array( 117 + '.', 118 + "= g\n", 119 + '- h', 120 + '+ X', 121 + "= \n", 122 + ), 123 + pht('Summary diff with last change.')); 124 + 125 + $this->assertProseParts( 126 + 'aaa aaa aaa aaa, bbb bbb bbb bbb.', 127 + "aaa aaa aaa aaa, bbb bbb bbb bbb.\n\n- ccc ccc ccc", 128 + array( 129 + '= aaa aaa aaa aaa, bbb bbb bbb bbb.', 130 + "+ \n\n- ccc ccc ccc", 131 + ), 132 + pht('Diff with new trailing content.')); 133 + 134 + $this->assertProseParts( 135 + 'aaa aaa aaa aaa, bbb bbb bbb bbb.', 136 + 'aaa aaa aaa aaa bbb bbb bbb bbb.', 137 + array( 138 + '= aaa aaa aaa aaa', 139 + '- ,', 140 + '= bbb bbb bbb bbb.', 141 + ), 142 + pht('Diff with a removed comma.')); 143 + 144 + $this->assertProseParts( 145 + 'aaa aaa aaa aaa, bbb bbb bbb bbb.', 146 + "aaa aaa aaa aaa bbb bbb bbb bbb.\n\n- ccc ccc ccc!", 147 + array( 148 + '= aaa aaa aaa aaa', 149 + '- ,', 150 + '= bbb bbb bbb bbb.', 151 + "+ \n\n- ccc ccc ccc!", 152 + ), 153 + pht('Diff with a removed comma and new trailing content.')); 154 + 155 + $this->assertProseParts( 156 + '[ ] Walnuts', 157 + '[X] Walnuts', 158 + array( 159 + '= [', 160 + '- ', 161 + '+ X', 162 + '= ] Walnuts', 163 + ), 164 + pht('Diff adding a tickmark to a checkbox list.')); 165 + 166 + $this->assertProseParts( 167 + '[[ ./week49 ]]', 168 + '[[ ./week50 ]]', 169 + array( 170 + '= [[ ./week', 171 + '- 49', 172 + '+ 50', 173 + '= ]]', 174 + ), 175 + pht('Diff changing a remarkup wiki link target.')); 176 + 177 + // Create a large corpus with many sentences and paragraphs. 178 + $large_paragraph = 'xyz. '; 179 + $large_paragraph = str_repeat($large_paragraph, 50); 180 + $large_paragraph = rtrim($large_paragraph); 181 + 182 + $large_corpus = $large_paragraph."\n\n"; 183 + $large_corpus = str_repeat($large_corpus, 50); 184 + $large_corpus = rtrim($large_corpus); 185 + 186 + $this->assertProseParts( 187 + $large_corpus, 188 + "aaa\n\n".$large_corpus."\n\nzzz", 189 + array( 190 + "+ aaa\n\n", 191 + '= '.$large_corpus, 192 + "+ \n\nzzz", 193 + ), 194 + pht('Adding initial and final lines to a large corpus.')); 195 + 196 + } 197 + 198 + private function assertProseParts($old, $new, array $expect_parts, $label) { 199 + $engine = new PhutilProseDifferenceEngine(); 200 + $diff = $engine->getDiff($old, $new); 201 + 202 + $parts = $diff->getParts(); 203 + 204 + $this->assertParts($expect_parts, $parts, $label); 205 + } 206 + 207 + private function assertSummaryProseParts( 208 + $old, 209 + $new, 210 + array $expect_parts, 211 + $label) { 212 + 213 + $engine = new PhutilProseDifferenceEngine(); 214 + $diff = $engine->getDiff($old, $new); 215 + 216 + $parts = $diff->getSummaryParts(); 217 + 218 + $this->assertParts($expect_parts, $parts, $label); 219 + } 220 + 221 + private function assertParts( 222 + array $expect, 223 + array $actual_parts, 224 + $label) { 225 + 226 + $actual = array(); 227 + foreach ($actual_parts as $actual_part) { 228 + $type = $actual_part['type']; 229 + $text = $actual_part['text']; 230 + 231 + switch ($type) { 232 + case '.': 233 + $actual[] = $type; 234 + break; 235 + default: 236 + $actual[] = "{$type} {$text}"; 237 + break; 238 + } 239 + } 240 + 241 + $this->assertEqual($expect, $actual, $label); 242 + } 243 + 244 + 245 + }