Comma separate keyframe percentage lists (#143)

Fixes #105 

Check for `PercentageTerm` expressions when emitting CSS,
separate neighboring percentage terms with commas.

Lowercase the `AND` for media queries.

Fix some typos in comments.

Co-authored-by: zzwx <8169082+zzwx@users.noreply.github.com>
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 18907e1..2ff264d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,7 @@
 ## 0.17.2
 
+- Comma separate keyframe percentage lists.
+- Use lowercase `and` in media queries.
 - Fixed a crash caused by `min()`, `max()` and `clamp()` functions that contain
   mathematical expressions.
 
diff --git a/lib/src/css_printer.dart b/lib/src/css_printer.dart
index 0f5b822..1eac334 100644
--- a/lib/src/css_printer.dart
+++ b/lib/src/css_printer.dart
@@ -55,7 +55,7 @@
 
   @override
   void visitMediaExpression(MediaExpression node) {
-    emit(node.andOperator ? ' AND ' : ' ');
+    emit(node.andOperator ? ' and ' : ' ');
     emit('(${node.mediaFeature}');
     if (node.exprs.expressions.isNotEmpty) {
       emit(':');
@@ -612,7 +612,7 @@
     var expressions = node.expressions;
     var expressionsLength = expressions.length;
     for (var i = 0; i < expressionsLength; i++) {
-      // Add space seperator between terms without an operator.
+      // Add space separator between terms without an operator.
       // TODO(terry): Should have a BinaryExpression to solve this problem.
       var expression = expressions[i];
       if (i > 0 &&
@@ -624,6 +624,9 @@
         var previous = expressions[i - 1];
         if (previous is OperatorComma || previous is OperatorSlash) {
           emit(_sp);
+        } else if (previous is PercentageTerm && expression is PercentageTerm) {
+          emit(',');
+          emit(_sp);
         } else {
           emit(' ');
         }
diff --git a/lib/src/tree.dart b/lib/src/tree.dart
index 3af71dc..3c66ac7 100644
--- a/lib/src/tree.dart
+++ b/lib/src/tree.dart
@@ -716,8 +716,7 @@
 
   bool get hasUnary => _mediaUnary != -1;
   String get unary =>
-      TokenKind.idToValue(TokenKind.MEDIA_OPERATORS, _mediaUnary)!
-          .toUpperCase();
+      TokenKind.idToValue(TokenKind.MEDIA_OPERATORS, _mediaUnary)!;
 
   @override
   SourceSpan get span => super.span!;
diff --git a/pubspec.yaml b/pubspec.yaml
index a71911b..e224b0a 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -13,4 +13,5 @@
 dev_dependencies:
   path: ^1.8.0
   pedantic: ^1.10.0
+  term_glyph: ^1.2.0
   test: ^1.16.0
diff --git a/test/declaration_test.dart b/test/declaration_test.dart
index 6cccc9c..64e84ce 100644
--- a/test/declaration_test.dart
+++ b/test/declaration_test.dart
@@ -348,7 +348,7 @@
   }
 }''';
   var generated = '''
-@media screen AND (-webkit-min-device-pixel-ratio:0) {
+@media screen and (-webkit-min-device-pixel-ratio:0) {
 .todo-item .toggle {
   background: none;
 }
@@ -381,7 +381,7 @@
     }
   }''';
   generated =
-      '''@media handheld AND (min-width:20em), screen AND (min-width:20em) {
+      '''@media handheld and (min-width:20em), screen and (min-width:20em) {
 #id {
   color: #f00;
 }
@@ -389,12 +389,12 @@
   height: 20px;
 }
 }
-@media print AND (min-resolution:300dpi) {
+@media print and (min-resolution:300dpi) {
 #anotherId {
   color: #fff;
 }
 }
-@media print AND (min-resolution:280dpcm) {
+@media print and (min-resolution:280dpcm) {
 #finalId {
   color: #aaa;
 }
@@ -415,8 +415,8 @@
         font-size: 10em;
       }
     }''';
-  generated = '@media ONLY screen AND (min-device-width:4000px) '
-      'AND (min-device-height:2000px), screen AND (another:100px) {\n'
+  generated = '@media only screen and (min-device-width:4000px) '
+      'and (min-device-height:2000px), screen and (another:100px) {\n'
       'html {\n  font-size: 10em;\n}\n}';
 
   stylesheet = parseCss(input, errors: errors..clear(), opts: simpleOptions);
@@ -431,8 +431,8 @@
         font-size: 10em;
       }
     }''';
-  generated = '@media screen, print AND (min-device-width:4000px) AND '
-      '(min-device-height:2000px), screen AND (another:100px) {\n'
+  generated = '@media screen, print and (min-device-width:4000px) and '
+      '(min-device-height:2000px), screen and (another:100px) {\n'
       'html {\n  font-size: 10em;\n}\n}';
 
   stylesheet = parseCss(input, errors: errors..clear(), opts: simpleOptions);
@@ -442,8 +442,8 @@
 
   input = '''
 @import "test.css" ONLY screen, NOT print AND (min-device-width: 4000px);''';
-  generated = '@import "test.css" ONLY screen, '
-      'NOT print AND (min-device-width:4000px);';
+  generated = '@import "test.css" only screen, '
+      'not print and (min-device-width:4000px);';
 
   stylesheet = parseCss(input, errors: errors..clear(), opts: simpleOptions);
 
@@ -453,10 +453,10 @@
   var css = '@media (min-device-width:400px) {\n}';
   expectCss(css, css);
 
-  css = '@media all AND (tranform-3d), (-webkit-transform-3d) {\n}';
+  css = '@media all and (transform-3d), (-webkit-transform-3d) {\n}';
   expectCss(css, css);
 
-  // Test that AND operator is required between media type and expressions.
+  // Test that 'and' operator is required between media type and expressions.
   css = '@media screen (min-device-width:400px';
   stylesheet = parseCss(css, errors: errors..clear(), opts: simpleOptions);
   expect(errors, isNotEmpty);
@@ -862,7 +862,7 @@
       '@page{@top-left{color:red}}'
       '@page:first{}'
       '@page foo:first{}'
-      '@media screen AND (max-width:800px){div{font-size:24px}}'
+      '@media screen and (max-width:800px){div{font-size:24px}}'
       '@keyframes foo{0%{transform:scaleX(0)}}'
       'div{color:rgba(0,0,0,0.2)}';
 
diff --git a/test/error_test.dart b/test/error_test.dart
index b6be913..3543502 100644
--- a/test/error_test.dart
+++ b/test/error_test.dart
@@ -131,7 +131,6 @@
   var input = '# foo { color: #ff00ff; }';
   parseCss(input, errors: errors);
 
-  expect(errors, isNotEmpty);
   expect(errors[0].toString(), '''
 error on line 1, column 1: Not a valid ID selector expected #id
   ,
diff --git a/test/keyframes_test.dart b/test/keyframes_test.dart
new file mode 100644
index 0000000..24a956c
--- /dev/null
+++ b/test/keyframes_test.dart
@@ -0,0 +1,141 @@
+library extend_test;
+
+import 'package:csslib/src/messages.dart';
+import 'package:test/test.dart';
+
+import 'testing.dart';
+
+void compileAndValidate(String input, String generated) {
+  var errors = <Message>[];
+  var stylesheet = compileCss(input, errors: errors, opts: options);
+  expect(errors.isEmpty, true, reason: errors.toString());
+  expect(prettyPrint(stylesheet), generated);
+}
+
+void singleKeyframesList() {
+  compileAndValidate(r'''
+@keyframes ping {
+  75%, 100% {
+    transform: scale(2);
+    opacity: 0;
+  }
+}''', r'''
+@keyframes ping {
+  75%, 100% {
+  transform: scale(2);
+  opacity: 0;
+  }
+}''');
+}
+
+void keyframesList() {
+  compileAndValidate(r'''
+@keyframes spin {
+  to {
+    transform: rotate(360deg);
+  }
+}
+@-webkit-keyframes ping {
+  75%, 100% {
+    transform: scale(2);
+    opacity: 0;
+  }
+}
+@keyframes ping {
+  75%, 100% {
+    transform: scale(2);
+    opacity: 0;
+  }
+}
+@-webkit-keyframes pulse {
+  50% {
+    opacity: 0.5;
+  }
+}
+@keyframes pulse {
+  50% {
+    opacity: 0.5;
+  }
+}
+@-webkit-keyframes bounce {
+  0%, 100% {
+    transform: translateY(-25%);
+    -webkit-animation-timing-function: cubic-bezier(0.8, 0, 1, 1);
+    animation-timing-function: cubic-bezier(0.8, 0, 1, 1);
+  }
+  50% {
+    transform: none;
+    -webkit-animation-timing-function: cubic-bezier(0, 0, 0.2, 1);
+    animation-timing-function: cubic-bezier(0, 0, 0.2, 1);
+  }
+}
+@keyframes bounce {
+  0%, 100% {
+    transform: translateY(-25%);
+    -webkit-animation-timing-function: cubic-bezier(0.8, 0, 1, 1);
+    animation-timing-function: cubic-bezier(0.8, 0, 1, 1);
+  }
+  50% {
+    transform: none;
+    -webkit-animation-timing-function: cubic-bezier(0, 0, 0.2, 1);
+    animation-timing-function: cubic-bezier(0, 0, 0.2, 1);
+  }
+}
+''', r'''
+@keyframes spin {
+  to {
+  transform: rotate(360deg);
+  }
+}
+@-webkit-keyframes ping {
+  75%, 100% {
+  transform: scale(2);
+  opacity: 0;
+  }
+}
+@keyframes ping {
+  75%, 100% {
+  transform: scale(2);
+  opacity: 0;
+  }
+}
+@-webkit-keyframes pulse {
+  50% {
+  opacity: 0.5;
+  }
+}
+@keyframes pulse {
+  50% {
+  opacity: 0.5;
+  }
+}
+@-webkit-keyframes bounce {
+  0%, 100% {
+  transform: translateY(-25%);
+  -webkit-animation-timing-function: cubic-bezier(0.8, 0, 1, 1);
+  animation-timing-function: cubic-bezier(0.8, 0, 1, 1);
+  }
+  50% {
+  transform: none;
+  -webkit-animation-timing-function: cubic-bezier(0, 0, 0.2, 1);
+  animation-timing-function: cubic-bezier(0, 0, 0.2, 1);
+  }
+}
+@keyframes bounce {
+  0%, 100% {
+  transform: translateY(-25%);
+  -webkit-animation-timing-function: cubic-bezier(0.8, 0, 1, 1);
+  animation-timing-function: cubic-bezier(0.8, 0, 1, 1);
+  }
+  50% {
+  transform: none;
+  -webkit-animation-timing-function: cubic-bezier(0, 0, 0.2, 1);
+  animation-timing-function: cubic-bezier(0, 0, 0.2, 1);
+  }
+}''');
+}
+
+void main() {
+  test('Single Keyframes List', singleKeyframesList);
+  test('Keyframes List', keyframesList);
+}
diff --git a/test/nested_test.dart b/test/nested_test.dart
index a594969..f099de8 100644
--- a/test/nested_test.dart
+++ b/test/nested_test.dart
@@ -353,7 +353,7 @@
   }
 }
 ''';
-  final generated = r'''@media screen AND (-webkit-min-device-pixel-ratio:0) {
+  final generated = r'''@media screen and (-webkit-min-device-pixel-ratio:0) {
 #toggle-all {
   image: url("test.jpb");
   color: #f00;
diff --git a/test/testing.dart b/test/testing.dart
index 2a51fdb..e41a810 100644
--- a/test/testing.dart
+++ b/test/testing.dart
@@ -57,13 +57,13 @@
 
 /// Pretty printer for CSS.
 String prettyPrint(StyleSheet ss) {
-  // Walk the tree testing basic Vistor class.
+  // Walk the tree testing basic Visitor class.
   walkTree(ss);
   return (_emitCss..visitTree(ss, pretty: true)).toString();
 }
 
 /// Helper function to emit compact (non-pretty printed) CSS for suite test
-/// comparsions.  Spaces, new lines, etc. are reduced for easier comparsions of
+/// comparisons.  Spaces, new lines, etc. are reduced for easier comparisons of
 /// expected suite test results.
 String compactOutput(StyleSheet ss) {
   walkTree(ss);